release: ship 0.22.1 media transport hardening

This commit is contained in:
Brad Stein 2026-05-10 23:14:15 -03:00
parent da7a49bc8c
commit 4c6010ece6
250 changed files with 12083 additions and 1306 deletions

8
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.21.17"
version = "0.22.1"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.21.17"
version = "0.22.1"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.21.17"
version = "0.22.1"
dependencies = [
"anyhow",
"base64",
@ -1728,7 +1728,7 @@ dependencies = [
]
[[package]]
name = "lesavka_testing"
name = "lesavka_tests"
version = "0.1.0"
dependencies = [
"anyhow",

View File

@ -1,9 +1,20 @@
[package]
name = "lesavka_tests"
version = "0.1.0"
edition = "2024"
publish = false
build = "build.rs"
autotests = false
[lib]
name = "lesavka_tests"
path = "tests/lib.rs"
[workspace]
members = [
"common",
"client",
"server",
"testing",
]
resolver = "3"
@ -11,3 +22,653 @@ resolver = "3"
serial_test = "3.2"
tempfile = "3.15"
temp-env = "0.3"
[features]
# Virtual HID tests create uinput keyboards/mice and can leak events into the
# active desktop. Keep them opt-in so default local and CI gates stay safe.
disruptive-input-tests = []
[dev-dependencies]
anyhow = "1.0"
async-stream = "0.3"
base64 = "0.22"
chrono = "0.4"
evdev = "0.13"
futures-util = "0.3"
libc = "0.2"
lesavka_client = { path = "client" }
lesavka_common = { path = "common" }
lesavka_server = { path = "server" }
chacha20poly1305 = "0.10"
gstreamer = { version = "0.23", features = ["v1_22"] }
gstreamer-app = { version = "0.23", features = ["v1_22"] }
gstreamer-video = { version = "0.23", features = ["v1_22"] }
gtk = { version = "0.8", package = "gtk4", features = ["v4_6"] }
winit = "0.30"
serial_test = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
shell-escape = "0.1"
temp-env = { workspace = true }
tempfile = { workspace = true }
tokio = { version = "1.45", features = ["full", "macros", "rt-multi-thread", "sync", "time"] }
tokio-stream = "0.1"
tonic = { version = "0.13", features = ["transport"] }
tonic-reflection = "0.13"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] }
udev = "0.8"
v4l = "0.14"
[[test]]
name = "client_relayctl_binary_contract"
path = "tests/api/client/bin/lesavka_relayctl/client_relayctl_binary_contract.rs"
[[test]]
name = "client_relayctl_process_contract"
path = "tests/api/client/bin/lesavka_relayctl/client_relayctl_process_contract.rs"
[[test]]
name = "client_keyboard_paste_rpc_contract"
path = "tests/api/client/input/keyboard/client_keyboard_paste_rpc_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "common_cli_binary_contract"
path = "tests/api/common/cli/common_cli_binary_contract.rs"
[[test]]
name = "common_cli_contract"
path = "tests/api/common/cli/common_cli_contract.rs"
[[test]]
name = "handshake_camera_contract"
path = "tests/api/common/handshake/handshake_camera_contract.rs"
[[test]]
name = "server_main_rpc_contract"
path = "tests/api/server/main/server_main_rpc_contract.rs"
[[test]]
name = "server_main_rpc_reset_contract"
path = "tests/api/server/main/server_main_rpc_reset_contract.rs"
[[test]]
name = "server_main_state_rpc_contract"
path = "tests/api/server/main/server_main_state_rpc_contract.rs"
[[test]]
name = "server_auth_rpc_contract"
path = "tests/api/server/auth/server_auth_rpc_contract.rs"
[[test]]
name = "server_upstream_media_audio_contract"
path = "tests/api/server/upstream_media_runtime/server_upstream_media_audio_contract.rs"
[[test]]
name = "server_upstream_media_bundle_contract"
path = "tests/api/server/upstream_media_runtime/server_upstream_media_bundle_contract.rs"
[[test]]
name = "server_upstream_media_contract"
path = "tests/api/server/upstream_media_runtime/server_upstream_media_contract.rs"
[[test]]
name = "server_upstream_media_pairing_contract"
path = "tests/api/server/upstream_media_runtime/server_upstream_media_pairing_contract.rs"
[[test]]
name = "server_upstream_media_v2_handoff_contract"
path = "tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs"
[[test]]
name = "server_upstream_media_video_contract"
path = "tests/api/server/upstream_media_runtime/server_upstream_media_video_contract.rs"
[[test]]
name = "uplink_backpressure_chaos_contract"
path = "tests/chaos/client/uplink/uplink_backpressure_chaos_contract.rs"
[[test]]
name = "hevc_mjpeg_guard_chaos_contract"
path = "tests/chaos/server/video_sinks/hevc_mjpeg_guard_chaos_contract.rs"
[[test]]
name = "client_server_rct_route_chaos_contract"
path = "tests/chaos/client/server/rct/client_server_rct_route_chaos_contract.rs"
[[test]]
name = "input_disconnect_chaos_contract"
path = "tests/chaos/input/input_disconnect_chaos_contract.rs"
[[test]]
name = "input_packet_ordering_chaos_contract"
path = "tests/chaos/input/input_packet_ordering_chaos_contract.rs"
[[test]]
name = "downstream_video_stall_chaos_contract"
path = "tests/chaos/downstream/video/downstream_video_stall_chaos_contract.rs"
[[test]]
name = "interrupted_install_safe_state_contract"
path = "tests/chaos/system/interrupted_install_safe_state_contract.rs"
[[test]]
name = "client_video_support_include_contract"
path = "tests/compatibility/client/video_support/client_video_support_include_contract.rs"
[[test]]
name = "client_opus_transport_contract"
path = "tests/compatibility/client/audio/client_opus_transport_contract.rs"
[[test]]
name = "server_camera_runtime_contract"
path = "tests/compatibility/server/camera/server_camera_runtime_contract.rs"
[[test]]
name = "server_uvc_runtime_contract"
path = "tests/compatibility/server/uvc/server_uvc_runtime_contract.rs"
[[test]]
name = "hevc_mjpeg_profile_matrix_contract"
path = "tests/compatibility/server/video/hevc_mjpeg_profile_matrix_contract.rs"
[[test]]
name = "video_support_contract"
path = "tests/compatibility/video/video_support_contract.rs"
[[test]]
name = "client_uplink_component_contract"
path = "tests/component/client/uplink/client_uplink_component_contract.rs"
[[test]]
name = "client_app_include_contract"
path = "tests/contract/client/app/client_app_include_contract.rs"
[[test]]
name = "client_app_process_contract"
path = "tests/contract/client/app/client_app_process_contract.rs"
[[test]]
name = "client_camera_include_contract"
path = "tests/contract/client/input/camera/client_camera_include_contract.rs"
[[test]]
name = "client_camera_timing_contract"
path = "tests/contract/client/input/camera/client_camera_timing_contract.rs"
[[test]]
name = "client_inputs_contract"
path = "tests/contract/client/input/inputs/client_inputs_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_inputs_extra_contract"
path = "tests/contract/client/input/inputs/client_inputs_extra_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_inputs_routing_contract"
path = "tests/contract/client/input/inputs/client_inputs_routing_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_keyboard_clipboard_contract"
path = "tests/contract/client/input/keyboard/client_keyboard_clipboard_contract.rs"
[[test]]
name = "client_keyboard_include_contract"
path = "tests/contract/client/input/keyboard/client_keyboard_include_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_keyboard_include_extra_contract"
path = "tests/contract/client/input/keyboard/client_keyboard_include_extra_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_keyboard_process_contract"
path = "tests/contract/client/input/keyboard/client_keyboard_process_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_keyboard_shift_contract"
path = "tests/contract/client/input/keyboard/client_keyboard_shift_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_keymap_contract"
path = "tests/contract/client/input/keyboard/client_keymap_contract.rs"
[[test]]
name = "client_microphone_gain_control_contract"
path = "tests/contract/client/input/microphone/client_microphone_gain_control_contract.rs"
[[test]]
name = "client_microphone_include_contract"
path = "tests/contract/client/input/microphone/client_microphone_include_contract.rs"
[[test]]
name = "client_microphone_requested_source_contract"
path = "tests/contract/client/input/microphone/client_microphone_requested_source_contract.rs"
[[test]]
name = "client_microphone_source_contract"
path = "tests/contract/client/input/microphone/client_microphone_source_contract.rs"
[[test]]
name = "client_microphone_startup_contract"
path = "tests/contract/client/input/microphone/client_microphone_startup_contract.rs"
[[test]]
name = "client_microphone_tap_contract"
path = "tests/contract/client/input/microphone/client_microphone_tap_contract.rs"
[[test]]
name = "client_mouse_include_contract"
path = "tests/contract/client/input/mouse/client_mouse_include_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_mouse_include_extra_contract"
path = "tests/contract/client/input/mouse/client_mouse_include_extra_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_mouse_uinput_contract"
path = "tests/contract/client/input/mouse/client_mouse_uinput_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_main_binary_contract"
path = "tests/contract/client/main/client_main_binary_contract.rs"
[[test]]
name = "client_main_process_contract"
path = "tests/contract/client/main/client_main_process_contract.rs"
[[test]]
name = "client_output_audio_include_contract"
path = "tests/contract/client/output/audio/client_output_audio_include_contract.rs"
[[test]]
name = "client_output_display_include_contract"
path = "tests/contract/client/output/display/client_output_display_include_contract.rs"
[[test]]
name = "client_output_video_include_contract"
path = "tests/contract/client/output/video/client_output_video_include_contract.rs"
[[test]]
name = "client_paste_contract"
path = "tests/contract/client/paste/client_paste_contract.rs"
[[test]]
name = "client_browser_sync_script_contract"
path = "tests/contract/client/sync_probe/client_browser_sync_script_contract.rs"
[[test]]
name = "client_hevc_bundle_audit_contract"
path = "tests/contract/client/sync_probe/client_hevc_bundle_audit_contract.rs"
[[test]]
name = "shared_hid_contract"
path = "tests/contract/common/hid/shared_hid_contract.rs"
[[test]]
name = "server_core_script_contract"
path = "tests/contract/scripts/daemon/server_core_script_contract.rs"
[[test]]
name = "server_audio_include_contract"
path = "tests/contract/server/audio/server_audio_include_contract.rs"
[[test]]
name = "server_opus_uac_contract"
path = "tests/contract/server/audio/server_opus_uac_contract.rs"
[[test]]
name = "server_camera_contract"
path = "tests/contract/server/camera/server_camera_contract.rs"
[[test]]
name = "server_gadget_include_contract"
path = "tests/contract/server/gadget/server_gadget_include_contract.rs"
[[test]]
name = "server_main_binary_contract"
path = "tests/contract/server/main/server_main_binary_contract.rs"
[[test]]
name = "server_main_binary_extra_contract"
path = "tests/contract/server/main/server_main_binary_extra_contract.rs"
[[test]]
name = "server_main_eye_hub_contract"
path = "tests/contract/server/main/server_main_eye_hub_contract.rs"
[[test]]
name = "server_main_media_extra_contract"
path = "tests/contract/server/main/server_main_media_extra_contract.rs"
[[test]]
name = "server_main_process_contract"
path = "tests/contract/server/main/server_main_process_contract.rs"
[[test]]
name = "server_runtime_contract"
path = "tests/contract/server/runtime_support/server_runtime_contract.rs"
[[test]]
name = "server_uvc_binary_contract"
path = "tests/contract/server/uvc/server_uvc_binary_contract.rs"
[[test]]
name = "server_uvc_binary_extra_contract"
path = "tests/contract/server/uvc/server_uvc_binary_extra_contract.rs"
[[test]]
name = "server_uvc_process_contract"
path = "tests/contract/server/uvc/server_uvc_process_contract.rs"
[[test]]
name = "server_uvc_script_contract"
path = "tests/contract/server/uvc/server_uvc_script_contract.rs"
[[test]]
name = "server_video_include_contract"
path = "tests/contract/server/video/server_video_include_contract.rs"
[[test]]
name = "downstream_video_mode_decoder_matrix_contract"
path = "tests/compatibility/downstream/video/downstream_video_mode_decoder_matrix_contract.rs"
[[test]]
name = "server_video_sinks_include_contract"
path = "tests/contract/server/video_sinks/server_video_sinks_include_contract.rs"
[[test]]
name = "report_schema_contract"
path = "tests/contract/diagnostics/report_schema_contract.rs"
[[test]]
name = "quality_ratchet_evidence_contract"
path = "tests/contract/testing/quality_ratchet_evidence_contract.rs"
[[test]]
name = "upstream_media_e2e_contract"
path = "tests/e2e/scripts/manual/upstream_media_e2e_contract.rs"
[[test]]
name = "client_rct_transport_summary_golden_contract"
path = "tests/golden/diagnostics/client_rct_transport_summary_golden_contract.rs"
[[test]]
name = "client_server_rct_blind_route_e2e_contract"
path = "tests/e2e/client/server/rct/client_server_rct_blind_route_e2e_contract.rs"
[[test]]
name = "server_rct_output_probe_e2e_contract"
path = "tests/e2e/server/rct/server_rct_output_probe_e2e_contract.rs"
[[test]]
name = "client_install_script_contract"
path = "tests/installer/scripts/install/client_install_script_contract.rs"
[[test]]
name = "server_install_script_contract"
path = "tests/installer/scripts/install/server_install_script_contract.rs"
[[test]]
name = "install_version_path_contract"
path = "tests/installer/scripts/install/install_version_path_contract.rs"
[[test]]
name = "relay_proto_integration_contract"
path = "tests/integration/common/proto/relay_proto_integration_contract.rs"
[[test]]
name = "relay_opus_proto_integration_contract"
path = "tests/integration/common/proto/relay_opus_proto_integration_contract.rs"
[[test]]
name = "client_server_upstream_bundle_integration"
path = "tests/integration/client/server/upstream/client_server_upstream_bundle_integration.rs"
[[test]]
name = "client_server_audio_epoch_integration"
path = "tests/integration/client/server/audio/client_server_audio_epoch_integration.rs"
[[test]]
name = "client_live_media_control_integration"
path = "tests/integration/client/runtime_controls/client_live_media_control_integration.rs"
[[test]]
name = "client_server_input_stream_integration"
path = "tests/integration/client/server/input/client_server_input_stream_integration.rs"
[[test]]
name = "hevc_mjpeg_spool_integration"
path = "tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs"
[[test]]
name = "client_manual_sync_script_contract"
path = "tests/manual/client/sync_probe/client_manual_sync_script_contract.rs"
[[test]]
name = "client_rct_transport_probe_contract"
path = "tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs"
[[test]]
name = "client_server_rc_matrix_script_contract"
path = "tests/manual/client/sync_probe/client_server_rc_matrix_script_contract.rs"
[[test]]
name = "server_rct_mode_matrix_manual_contract"
path = "tests/manual/server/rct/server_rct_mode_matrix_manual_contract.rs"
[[test]]
name = "google_meet_observer_manual_contract"
path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs"
[[test]]
name = "uvc_frame_meta_log_contract"
path = "tests/manual/scripts/manual/uvc_frame_meta_log_contract.rs"
[[test]]
name = "probe_artifact_contract"
path = "tests/manual/artifacts/probe_artifact_contract.rs"
[[test]]
name = "client_uplink_performance_contract"
path = "tests/performance/client/uplink/client_uplink_performance_contract.rs"
[[test]]
name = "opus_transport_budget_contract"
path = "tests/performance/client/uplink/opus_transport_budget_contract.rs"
[[test]]
name = "hevc_mjpeg_handoff_performance_contract"
path = "tests/performance/server/video_sinks/hevc_mjpeg_handoff_performance_contract.rs"
[[test]]
name = "server_rct_quality_budget_contract"
path = "tests/performance/server/rct/server_rct_quality_budget_contract.rs"
[[test]]
name = "client_server_rct_timing_budget_contract"
path = "tests/performance/client/server/rct/client_server_rct_timing_budget_contract.rs"
[[test]]
name = "input_latency_budget_contract"
path = "tests/performance/input/input_latency_budget_contract.rs"
[[test]]
name = "downstream_video_latency_budget_contract"
path = "tests/performance/downstream/video/downstream_video_latency_budget_contract.rs"
[[test]]
name = "performance_gate_script_contract"
path = "tests/performance/scripts/ci/performance_gate_script_contract.rs"
[[test]]
name = "stage_timing_contract"
path = "tests/performance/diagnostics/stage_timing_contract.rs"
[[test]]
name = "client_inputs_toggle_contract"
path = "tests/regression/client/input/inputs/client_inputs_toggle_contract.rs"
[[test]]
name = "client_keyboard_activation_contract"
path = "tests/regression/client/input/keyboard/client_keyboard_activation_contract.rs"
required-features = ["disruptive-input-tests"]
[[test]]
name = "client_live_controls_regression_contract"
path = "tests/regression/client/ui/client_live_controls_regression_contract.rs"
[[test]]
name = "install_preserves_calibration_contract"
path = "tests/regression/install/install_preserves_calibration_contract.rs"
[[test]]
name = "install_preserves_codec_settings_contract"
path = "tests/regression/install/install_preserves_codec_settings_contract.rs"
[[test]]
name = "server_gadget_recovery_contract"
path = "tests/regression/server/gadget/server_gadget_recovery_contract.rs"
[[test]]
name = "server_main_usb_recovery_contract"
path = "tests/regression/server/main/server_main_usb_recovery_contract.rs"
[[test]]
name = "client_log_noise_contract"
path = "tests/reliability/client/diagnostics/client_log_noise_contract.rs"
[[test]]
name = "input_transport_gate_safety_contract"
path = "tests/reliability/scripts/ci/input_transport_gate_safety_contract.rs"
[[test]]
name = "jenkins_cadence_contract"
path = "tests/reliability/scripts/ci/jenkins_cadence_contract.rs"
[[test]]
name = "log_spam_prevention_contract"
path = "tests/reliability/diagnostics/log_spam_prevention_contract.rs"
[[test]]
name = "client_uplink_freshness_contract"
path = "tests/reliability/client/uplink/client_uplink_freshness_contract.rs"
[[test]]
name = "server_upstream_media_pairing_freshness_contract"
path = "tests/reliability/server/upstream_media_runtime/server_upstream_media_pairing_freshness_contract.rs"
[[test]]
name = "audio_epoch_recovery_reliability_contract"
path = "tests/reliability/audio/audio_epoch_recovery_reliability_contract.rs"
[[test]]
name = "server_rct_profile_switch_recovery_contract"
path = "tests/reliability/server/rct/server_rct_profile_switch_recovery_contract.rs"
[[test]]
name = "video_downstream_feed_contract"
path = "tests/reliability/video/video_downstream_feed_contract.rs"
[[test]]
name = "downstream_blackout_recovery_contract"
path = "tests/reliability/downstream/video/downstream_blackout_recovery_contract.rs"
[[test]]
name = "tls_security_contract"
path = "tests/security/scripts/install/tls_security_contract.rs"
[[test]]
name = "client_paste_security_contract"
path = "tests/security/client/paste/client_paste_security_contract.rs"
[[test]]
name = "cert_key_permissions_contract"
path = "tests/security/install/cert_key_permissions_contract.rs"
[[test]]
name = "server_tls_security_contract"
path = "tests/security/server/tls/server_tls_security_contract.rs"
[[test]]
name = "upstream_media_payload_security_contract"
path = "tests/security/server/upstream_media/upstream_media_payload_security_contract.rs"
[[test]]
name = "client_runtime_smoke_contract"
path = "tests/smoke/client/runtime/client_runtime_smoke_contract.rs"
[[test]]
name = "server_runtime_smoke_contract"
path = "tests/smoke/server/runtime_support/server_runtime_smoke_contract.rs"
[[test]]
name = "server_video_sink_smoke_contract"
path = "tests/smoke/server/video_sinks/server_video_sink_smoke_contract.rs"
[[test]]
name = "system_installation_contract"
path = "tests/system/scripts/install/system_installation_contract.rs"
[[test]]
name = "systemd_unit_env_contract"
path = "tests/system/scripts/install/systemd_unit_env_contract.rs"
[[test]]
name = "client_launcher_layout_contract"
path = "tests/ui/client/launcher/client_launcher_layout_contract.rs"
[[test]]
name = "client_launcher_runtime_contract"
path = "tests/ui/client/launcher/client_launcher_runtime_contract.rs"
[[test]]
name = "client_audio_recovery_ui_contract"
path = "tests/ui/client/launcher/client_audio_recovery_ui_contract.rs"
[[test]]
name = "client_codec_transport_ui_contract"
path = "tests/ui/client/launcher/client_codec_transport_ui_contract.rs"
[[test]]
name = "client_layout_contract"
path = "tests/ui/client/launcher/client_layout_contract.rs"
[[test]]
name = "common_hid_unit"
path = "tests/unit/common/hid/common_hid_unit.rs"
[[test]]
name = "common_audio_transport_unit"
path = "tests/unit/common/audio/common_audio_transport_unit.rs"
[[test]]
name = "common_hid_edge_cases_unit"
path = "tests/unit/common/hid/common_hid_edge_cases_unit.rs"
[[test]]
name = "client_upstream_bundle_queue_unit"
path = "tests/unit/client/uplink/client_upstream_bundle_queue_unit.rs"
[[test]]
name = "client_upstream_keyframe_state_unit"
path = "tests/unit/client/uplink/client_upstream_keyframe_state_unit.rs"
[[test]]
name = "client_audio_recovery_config_unit"
path = "tests/unit/client/app/client_audio_recovery_config_unit.rs"
[[test]]
name = "hevc_mjpeg_guard_unit"
path = "tests/unit/server/video_sinks/hevc_mjpeg_guard_unit.rs"

78
Jenkinsfile vendored
View File

@ -32,6 +32,9 @@ spec:
parameters {
booleanParam(name: 'PUSH_IMAGES', defaultValue: false, description: 'Push images to registry (enable for release runs)')
choice(name: 'LESAVKA_CI_PROFILE', choices: ['safe', 'daily', 'lab'], description: 'Safe is the normal non-disruptive gate; daily is intended for scheduled master/main runs; lab enables explicitly configured bare-metal probes.')
booleanParam(name: 'RUN_DISRUPTIVE_INPUT_TESTS', defaultValue: false, description: 'Run virtual HID tests only on an isolated worker/session; these can emit keyboard/mouse events.')
booleanParam(name: 'RUN_LAB_HARDWARE_GATES', defaultValue: false, description: 'Run opt-in bare-metal lab gates for Theia/Tethys/RCT probes when the Jenkins worker is prepared for them.')
string(name: 'QUALITY_GATE_PUSHGATEWAY_URL', defaultValue: 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091', description: 'Pushgateway base URL for quality gate metrics')
string(name: 'REGISTRY_CREDENTIALS_ID', defaultValue: 'registry-bstein-dev', description: 'Jenkins credentials id for registry.bstein.dev')
}
@ -41,6 +44,7 @@ spec:
IMAGE_PREFIX = "${REGISTRY}/lesavka"
CARGO_TERM_COLOR = 'always'
DOCKER_BUILDKIT = '1'
LESAVKA_CI_PROFILE = "${params.LESAVKA_CI_PROFILE}"
}
stages {
@ -83,7 +87,21 @@ spec:
}
}
stage('Daily Master Gate') {
when {
expression { return params.LESAVKA_CI_PROFILE == 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/daily_master_gate.sh'
}
}
}
stage('Style Docs LOC Naming') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'scripts/ci/hygiene_gate.sh'
@ -92,6 +110,9 @@ spec:
}
stage('Coverage') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/quality_gate.sh'
@ -100,6 +121,9 @@ spec:
}
stage('Tests') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/test_gate.sh'
@ -107,7 +131,21 @@ spec:
}
}
stage('Performance') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/performance_gate.sh'
}
}
}
stage('Media Reliability') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/media_reliability_gate.sh'
@ -115,7 +153,32 @@ spec:
}
}
stage('Input Transport (Isolated Opt-In)') {
when {
expression { return params.RUN_DISRUPTIVE_INPUT_TESTS }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS=1 scripts/ci/input_transport_gate.sh'
}
}
}
stage('Bare-Metal Lab Gates (Opt-In)') {
when {
expression { return params.RUN_LAB_HARDWARE_GATES || params.LESAVKA_CI_PROFILE == 'lab' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" LESAVKA_ALLOW_LAB_HARDWARE_TESTS=1 scripts/ci/baremetal_lab_gate.sh'
}
}
}
stage('Gate Glue') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/gate_glue_gate.sh'
@ -124,6 +187,9 @@ spec:
}
stage('SonarQube') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/sonarqube_gate.sh'
@ -132,6 +198,9 @@ spec:
}
stage('Build Dist') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'scripts/ci/build-dist.sh'
@ -140,6 +209,9 @@ spec:
}
stage('Supply Chain Artifact Security') {
when {
expression { return params.LESAVKA_CI_PROFILE != 'daily' }
}
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/supply_chain_gate.sh'
@ -149,7 +221,7 @@ spec:
stage('Docker Login') {
when {
expression { return params.PUSH_IMAGES }
expression { return params.LESAVKA_CI_PROFILE != 'daily' && params.PUSH_IMAGES }
}
steps {
withCredentials([
@ -166,7 +238,7 @@ spec:
stage('Build Images') {
when {
expression { return params.PUSH_IMAGES }
expression { return params.LESAVKA_CI_PROFILE != 'daily' && params.PUSH_IMAGES }
}
steps {
sh 'PUSH_IMAGES=${PUSH_IMAGES} scripts/ci/build-images.sh'
@ -178,7 +250,7 @@ spec:
always {
script {
try {
archiveArtifacts artifacts: 'dist/**,target/test-gate/**,target/quality-gate/**,target/hygiene-gate/**,target/media-reliability-gate/**,target/gate-glue-gate/**,target/sonarqube-gate/**,target/supply-chain-gate/**', fingerprint: true, allowEmptyArchive: true
archiveArtifacts artifacts: 'dist/**,target/test-gate/**,target/quality-gate/**,target/hygiene-gate/**,target/daily-master-gate/**,target/performance-gate/**,target/media-reliability-gate/**,target/input-transport-gate/**,target/baremetal-lab-gate/**,target/video-downstream-gate/**,target/gate-glue-gate/**,target/sonarqube-gate/**,target/supply-chain-gate/**', fingerprint: true, allowEmptyArchive: true
} catch (Throwable err) {
echo "archive step unavailable: ${err.class.simpleName}"
}

View File

@ -4,7 +4,7 @@ fn main() {
println!("cargo:rustc-check-cfg=cfg(coverage)");
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("manifest dir"));
let workspace_dir = manifest_dir.parent().expect("workspace dir");
let workspace_dir = manifest_dir;
let server_uvc = workspace_dir
.join("server/src/bin/lesavka-uvc.rs")

View File

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

View File

@ -1,4 +1,3 @@
#[cfg(not(coverage))]
fn audio_usb_auto_recover_enabled() -> bool {
std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_USB")
.map(|raw| {
@ -10,7 +9,6 @@ fn audio_usb_auto_recover_enabled() -> bool {
.unwrap_or(false)
}
#[cfg(not(coverage))]
fn audio_usb_recover_after() -> u32 {
std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_AFTER")
.ok()
@ -19,7 +17,6 @@ fn audio_usb_recover_after() -> u32 {
.unwrap_or(3)
}
#[cfg(not(coverage))]
fn audio_usb_recover_cooldown() -> Duration {
let millis = std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_COOLDOWN_MS")
.ok()
@ -28,7 +25,6 @@ fn audio_usb_recover_cooldown() -> Duration {
Duration::from_millis(millis)
}
#[cfg(not(coverage))]
fn is_recoverable_remote_audio_error(message: &str) -> bool {
message.contains("remote speaker capture produced no audio samples")
|| message.contains("remote speaker capture stalled")

View File

@ -62,12 +62,15 @@ impl LesavkaClientApp {
let initial_cam_profile = initial_camera_profile_id_from_env();
let initial_mic_source = std::env::var("LESAVKA_MIC_SOURCE").ok();
let initial_audio_sink = std::env::var("LESAVKA_AUDIO_SINK").ok();
let initial_audio_codec = crate::input::audio_codec::requested_upstream_audio_codec_from_env();
let initial_noise_suppression =
env_flag_enabled("LESAVKA_MIC_NOISE_SUPPRESSION").unwrap_or(false);
let camera_enabled = caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err();
let microphone_available = caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err();
let bundled_webcam_media =
camera_enabled && microphone_available && caps.bundled_webcam_media;
let media_controls = crate::live_media_control::LiveMediaControls::from_env(
crate::live_media_control::MediaControlState::with_devices(
crate::live_media_control::MediaControlState::with_devices_and_audio(
camera_enabled,
microphone_available || bundled_webcam_media,
std::env::var("LESAVKA_AUDIO_DISABLE").is_err(),
@ -75,6 +78,8 @@ impl LesavkaClientApp {
initial_cam_profile.clone(),
initial_mic_source.clone(),
initial_audio_sink.clone(),
initial_audio_codec,
initial_noise_suppression,
),
);
let media_state = media_controls.refresh();
@ -344,3 +349,12 @@ impl LesavkaClientApp {
}
}
fn env_flag_enabled(name: &str) -> Option<bool> {
let raw = std::env::var(name).ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}

View File

@ -42,6 +42,12 @@ fn emit_bundled_media(
audio,
..UpstreamMediaBundle::default()
};
if has_audio {
let profile = lesavka_common::audio_transport::packet_audio_profile(
bundle.audio.first().expect("audio was checked as present"),
);
lesavka_common::audio_transport::mark_bundle_audio_profile(&mut bundle, profile);
}
attach_bundle_queue_metadata(&mut bundle, 0, enqueue_age);
let stats = queue.push(bundle, enqueue_age);
if stats.dropped_queue_full > 0 {
@ -174,6 +180,9 @@ fn attach_audio_queue_metadata(
queue_depth: usize,
delivery_age: Duration,
) {
if packet.encoding == 0 {
lesavka_common::audio_transport::mark_packet_pcm_s16le(packet);
}
if packet.seq == 0 {
let _ = stamp_audio_timing_metadata_at_enqueue(packet);
}

View File

@ -1,4 +1,3 @@
#[cfg(not(coverage))]
/// Detect whether an Annex-B HEVC access unit contains an intra recovery point.
///
/// Inputs: one encoded HEVC packet in byte-stream form. Output: `true` when the
@ -20,7 +19,6 @@ fn contains_hevc_irap(data: &[u8]) -> bool {
false
}
#[cfg(not(coverage))]
/// Locate the next Annex-B start code in an encoded video packet.
///
/// Inputs: encoded bytes plus the search offset. Output: the start index and
@ -42,7 +40,6 @@ fn find_annex_b_start_code(data: &[u8], from: usize) -> Option<(usize, usize)> {
None
}
#[cfg(not(coverage))]
/// Decide whether a bundled HEVC packet is safe after a freshness drop.
///
/// Inputs: the recovery state and the next outbound bundle. Output: `true`
@ -60,7 +57,6 @@ fn should_hold_hevc_bundle_for_keyframe_recovery(
.is_some_and(|video| !contains_hevc_irap(&video.data))
}
#[cfg(not(coverage))]
/// Report whether a bundle carries the HEVC recovery frame we were waiting for.
///
/// Inputs: an outbound media bundle. Output: true when its video packet can
@ -73,7 +69,6 @@ fn bundle_has_hevc_recovery_keyframe(bundle: &UpstreamMediaBundle) -> bool {
.is_some_and(|video| contains_hevc_irap(&video.data))
}
#[cfg(not(coverage))]
/// Mark HEVC as needing recovery after capture produced a gap.
///
/// Inputs: whether HEVC recovery applies and the mutable wait flag. Output:
@ -86,7 +81,6 @@ fn note_hevc_capture_gap(recover_hevc_after_drops: bool, waiting_for_keyframe: &
}
}
#[cfg(not(coverage))]
/// Resolve whether the active upstream camera codec needs HEVC recovery.
///
/// Inputs: the negotiated camera config plus optional env fallback. Output:

View File

@ -26,6 +26,10 @@ impl LesavkaClientApp {
}
let microphone_source_choice = state.microphone_source.clone();
let active_source = microphone_source_choice.resolve(initial_source.as_deref());
let active_audio_codec = state
.audio_codec
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
let active_noise_suppression = state.noise_suppression.resolve(false);
let use_default_source = matches!(
microphone_source_choice,
crate::live_media_control::MediaDeviceChoice::Auto
@ -33,9 +37,16 @@ impl LesavkaClientApp {
let setup_source = active_source.clone();
let result = tokio::task::spawn_blocking(move || {
if use_default_source {
MicrophoneCapture::new_default_source()
MicrophoneCapture::new_default_source_options(
active_audio_codec,
active_noise_suppression,
)
} else {
MicrophoneCapture::new_with_source(setup_source.as_deref())
MicrophoneCapture::new_with_source_options(
setup_source.as_deref(),
active_audio_codec,
active_noise_suppression,
)
}
})
.await;
@ -118,6 +129,8 @@ impl LesavkaClientApp {
let media_controls_thread = media_controls.clone();
let initial_source_thread = initial_source.clone();
let active_source_thread = active_source.clone();
let active_audio_codec_thread = active_audio_codec;
let active_noise_suppression_thread = active_noise_suppression;
let mic_worker = std::thread::spawn(move || {
let mut paused = false;
while stop_rx.try_recv().is_err() {
@ -125,17 +138,29 @@ impl LesavkaClientApp {
let desired_source = state
.microphone_source
.resolve(initial_source_thread.as_deref());
let desired_audio_codec = state
.audio_codec
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
let desired_noise_suppression =
state.noise_suppression.resolve(false);
if pause_when_camera_active && state.camera {
tracing::info!(
"🎤 microphone-only uplink yielding to bundled webcam A/V"
);
break;
}
if desired_source != active_source_thread {
if desired_source != active_source_thread
|| desired_audio_codec != active_audio_codec_thread
|| desired_noise_suppression != active_noise_suppression_thread
{
tracing::info!(
from = active_source_thread.as_deref().unwrap_or("auto"),
to = desired_source.as_deref().unwrap_or("auto"),
"🎤 microphone source changed; restarting live uplink pipeline"
from_codec = active_audio_codec_thread.as_id(),
to_codec = desired_audio_codec.as_id(),
from_noise_suppression = active_noise_suppression_thread,
to_noise_suppression = desired_noise_suppression,
"🎤 microphone route changed; restarting live uplink pipeline"
);
break;
}

View File

@ -34,6 +34,10 @@ impl LesavkaClientApp {
let active_microphone_source = state
.microphone_source
.resolve(initial_microphone_source.as_deref());
let active_audio_codec = state
.audio_codec
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
let active_noise_suppression = state.noise_suppression.resolve(false);
let capture_profile = active_camera_profile
.as_deref()
.and_then(parse_camera_profile_id);
@ -46,9 +50,16 @@ impl LesavkaClientApp {
let setup = tokio::task::spawn_blocking(move || {
let microphone = if use_default_microphone {
MicrophoneCapture::new_default_source()
MicrophoneCapture::new_default_source_options(
active_audio_codec,
active_noise_suppression,
)
} else {
MicrophoneCapture::new_with_source(setup_microphone_source.as_deref())
MicrophoneCapture::new_with_source_options(
setup_microphone_source.as_deref(),
active_audio_codec,
active_noise_suppression,
)
}?;
let camera = if camera_requested {
Some(CameraCapture::new_with_capture_profile(
@ -255,6 +266,8 @@ impl LesavkaClientApp {
let media_controls = media_controls.clone();
let initial_microphone_source = initial_microphone_source.clone();
let active_microphone_source = active_microphone_source.clone();
let active_audio_codec = active_audio_codec;
let active_noise_suppression = active_noise_suppression;
let active_camera_requested = camera_requested;
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
@ -262,9 +275,16 @@ impl LesavkaClientApp {
let desired_source = state
.microphone_source
.resolve(initial_microphone_source.as_deref());
let desired_audio_codec = state
.audio_codec
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
let desired_noise_suppression =
state.noise_suppression.resolve(false);
if state.camera != active_camera_requested
|| !(state.microphone || state.camera)
|| desired_source != active_microphone_source
|| desired_audio_codec != active_audio_codec
|| desired_noise_suppression != active_noise_suppression
{
stop.store(true, Ordering::Relaxed);
let _ = event_tx.try_send(BundledCaptureEvent::Restart);
@ -370,7 +390,6 @@ fn upstream_epoch_auto_heal_delay() -> Option<Duration> {
/// Inputs are optional raw environment values. Output is a bounded delay, or
/// `None` when the operator opted out. Why: the default needs to be safe enough
/// for real calls while tests can pin the behavior without racing process env.
#[cfg(not(coverage))]
fn parse_upstream_epoch_auto_heal_delay(
enabled: Option<&str>,
delay_ms: Option<&str>,
@ -385,7 +404,6 @@ fn parse_upstream_epoch_auto_heal_delay(
}
/// Return whether a text env value disables startup A/V epoch healing.
#[cfg(not(coverage))]
fn disables_upstream_epoch_auto_heal(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),

View File

@ -0,0 +1,193 @@
//! Client-side upstream audio codec helpers.
//!
//! Why: the capture path stays raw PCM so monitor/playback behavior remains
//! predictable, while this module optionally compresses packets just before
//! they enter the client-to-server transport bundle.
use anyhow::{Context, Result, bail};
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use lesavka_common::{
audio_transport::{
self, AudioTransportProfile, UpstreamAudioCodec, parse_upstream_audio_codec,
},
lesavka::AudioPacket,
};
use std::time::Duration;
const AUDIO_CODEC_ENV: &str = "LESAVKA_UPLINK_AUDIO_CODEC";
const AUDIO_CODEC_LEGACY_ENV: &str = "LESAVKA_AUDIO_CODEC";
const OPUS_PULL_TIMEOUT: Duration = Duration::from_millis(25);
/// 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.
#[must_use]
pub fn requested_upstream_audio_codec_from_env() -> UpstreamAudioCodec {
std::env::var(AUDIO_CODEC_ENV)
.ok()
.or_else(|| std::env::var(AUDIO_CODEC_LEGACY_ENV).ok())
.as_deref()
.and_then(parse_upstream_audio_codec)
.unwrap_or(UpstreamAudioCodec::Opus)
}
/// Low-latency Opus packet encoder for already-framed 48 kHz stereo PCM.
pub struct OpusPacketEncoder {
_pipeline: gst::Pipeline,
appsrc: gst_app::AppSrc,
appsink: gst_app::AppSink,
}
impl OpusPacketEncoder {
/// Build the GStreamer Opus encoder used by live upstream transport.
///
/// Inputs: installed GStreamer plugins. Output: a running appsrc/appsink
/// encoder. Why: keeping the encoder persistent avoids per-packet pipeline
/// startup latency and lets PCM remain the fallback when plugins are absent.
pub fn new() -> Result<Self> {
gst::init().context("gst init")?;
if gst::ElementFactory::find("opusenc").is_none() {
bail!("GStreamer opusenc plugin is not available");
}
let desc = "\
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 ! \
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
.by_name("src")
.context("missing opus encoder appsrc")?
.downcast::<gst_app::AppSrc>()
.expect("opus encoder appsrc");
let appsink = pipeline
.by_name("sink")
.context("missing opus encoder appsink")?
.downcast::<gst_app::AppSink>()
.expect("opus encoder appsink");
pipeline
.set_state(gst::State::Playing)
.context("start opus encoder pipeline")?;
Ok(Self {
_pipeline: pipeline,
appsrc,
appsink,
})
}
/// Encode one PCM packet, preserving capture timing on the returned packet.
pub fn encode_packet(&mut self, packet: AudioPacket) -> Result<Option<AudioPacket>> {
if !audio_transport::packet_is_raw_pcm_s16le(&packet) {
return Ok(Some(packet));
}
let mut buffer = gst::Buffer::from_slice(packet.data.clone());
if let Some(meta) = buffer.get_mut() {
let pts = gst::ClockTime::from_useconds(packet.pts);
meta.set_pts(Some(pts));
meta.set_dts(Some(pts));
meta.set_duration(Some(gst::ClockTime::from_useconds(u64::from(
packet.frame_duration_us.max(1),
))));
}
self.appsrc
.push_buffer(buffer)
.context("push pcm into opus encoder")?;
let Some(sample) = self.appsink.try_pull_sample(gst::ClockTime::from_nseconds(
OPUS_PULL_TIMEOUT.as_nanos().min(u128::from(u64::MAX)) as u64,
)) else {
return Ok(None);
};
let encoded = sample
.buffer()
.context("opus encoder sample missing buffer")?
.map_readable()
.context("map opus packet")?
.to_vec();
if encoded.is_empty() {
return Ok(None);
}
let mut output = AudioPacket {
data: encoded,
..packet
};
audio_transport::mark_packet_opus(&mut output);
Ok(Some(output))
}
/// Return the compressed profile this encoder produces.
#[must_use]
pub const fn profile(&self) -> AudioTransportProfile {
AudioTransportProfile::opus_voice()
}
}
impl Drop for OpusPacketEncoder {
fn drop(&mut self) {
let _ = self._pipeline.set_state(gst::State::Null);
}
}
#[cfg(test)]
mod tests {
use super::*;
use lesavka_common::lesavka::AudioEncoding;
#[test]
fn requested_audio_codec_defaults_to_opus_and_parses_pcm_fallback() {
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
);
});
});
temp_env::with_var(AUDIO_CODEC_ENV, Some("pcm"), || {
assert_eq!(
requested_upstream_audio_codec_from_env(),
UpstreamAudioCodec::PcmS16le
);
});
}
#[test]
fn opus_encoder_preserves_packet_timing_when_plugin_is_available() {
let _ = gst::init();
if gst::ElementFactory::find("opusenc").is_none() {
return;
}
let mut packet = AudioPacket {
pts: 123_000,
data: vec![0; AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize],
frame_duration_us: 20_000,
..AudioPacket::default()
};
let raw_len = packet.data.len();
audio_transport::mark_packet_pcm_s16le(&mut packet);
let mut encoder = OpusPacketEncoder::new().expect("opus encoder");
let encoded = encoder
.encode_packet(packet.clone())
.expect("encode")
.unwrap_or_else(|| packet.clone());
assert_eq!(encoded.pts, 123_000);
assert_eq!(encoded.sample_rate, 48_000);
assert_eq!(encoded.channels, 2);
if encoded.encoding == AudioEncoding::Opus as i32 {
assert!(
encoded.data.len() < raw_len,
"Opus packet should be smaller than raw PCM"
);
}
}
}

View File

@ -106,12 +106,12 @@ impl CameraCapture {
// Jetson (has nvvidconv) Desktop (falls back to videoconvert)
// ───────────────────────────────────────────────────────────────────
#[cfg(not(coverage))]
"nvh264enc" if have_nvvidconv =>
"nvh264enc" | "nvh265enc" if have_nvvidconv =>
format!(
"nvvidconv ! video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1 !"
),
#[cfg(not(coverage))]
"nvh264enc" /* else */ =>
"nvh264enc" | "nvh265enc" /* else */ =>
format!(
"videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !"
),
@ -121,7 +121,7 @@ impl CameraCapture {
"videoconvert ! video/x-raw,format=I420,width={width},height={height},framerate={fps}/1 !"
),
#[cfg(not(coverage))]
"vaapih264enc" =>
"vaapih264enc" | "vah265enc" | "vaapih265enc" | "v4l2h265enc" =>
format!(
"videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !"
),
@ -139,9 +139,9 @@ impl CameraCapture {
// );
// tracing::debug!(%desc, "📸 pipeline-desc");
// Build a pipeline that works for any of the three encoders.
// * nvh264enc needs NVMM memory caps;
// * vaapih264enc wants system-memory caps;
// * x264enc needs the usual raw caps.
// * NVIDIA encoders prefer NV12, using NVMM when Jetson's converter is present.
// * VAAPI/V4L2 hardware encoders also get explicit NV12 system-memory caps.
// * x264enc/x265enc keep their software-friendly raw caps.
let preview_tap_path = camera_preview_tap_path();
let preview_tap_branch = camera_preview_tap_branch(width, height, fps);
let source_raw_caps = format!(

View File

@ -3,7 +3,10 @@ use anyhow::{Context, Result, bail};
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use lesavka_common::lesavka::AudioPacket;
use lesavka_common::{
audio_transport::{self, UpstreamAudioCodec},
lesavka::AudioPacket,
};
use shell_escape::unix::escape;
#[cfg(not(coverage))]
use std::sync::atomic::{AtomicU64, Ordering};
@ -28,6 +31,7 @@ const MIC_PULSE_BUFFER_TIME_ENV: &str = "LESAVKA_MIC_PULSE_BUFFER_TIME_US";
const MIC_PULSE_LATENCY_TIME_ENV: &str = "LESAVKA_MIC_PULSE_LATENCY_TIME_US";
const MIC_PACKET_TARGET_DURATION_ENV: &str = "LESAVKA_MIC_PACKET_TARGET_US";
const REQUIRE_EXPLICIT_MEDIA_SOURCES_ENV: &str = "LESAVKA_REQUIRE_EXPLICIT_MEDIA_SOURCES";
const MIC_NOISE_SUPPRESSION_ENV: &str = "LESAVKA_MIC_NOISE_SUPPRESSION";
const MIC_SAMPLE_RATE: u64 = 48_000;
const MIC_CHANNELS: usize = 2;
const MIC_SAMPLE_BYTES: usize = std::mem::size_of::<i16>();
@ -45,6 +49,7 @@ pub struct MicrophoneCapture {
level_tap_running: Option<Arc<AtomicBool>>,
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser,
pending_packets: Mutex<VecDeque<AudioPacket>>,
audio_encoder: Mutex<Option<crate::input::audio_codec::OpusPacketEncoder>>,
}
include!("microphone/capture_runtime.rs");
@ -58,13 +63,20 @@ fn mic_level_tap_path() -> Option<PathBuf> {
/// Keeps `microphone_pipeline_desc` explicit because it sits on microphone capture setup, where host audio stacks expose different source names and latency controls.
/// Inputs are the typed parameters; output is the return value or side effect.
fn microphone_pipeline_desc(source_desc: &str, gain: f64, level_tap_enabled: bool) -> String {
fn microphone_pipeline_desc(
source_desc: &str,
gain: f64,
level_tap_enabled: bool,
noise_suppression: bool,
) -> String {
let gain = format_mic_gain_for_gst(gain);
let noise_stage = microphone_noise_suppression_stage(noise_suppression);
if level_tap_enabled {
format!(
"{source_desc} ! \
audioconvert ! audioresample ! \
audio/x-raw,format=S16LE,channels={MIC_CHANNELS},rate={MIC_SAMPLE_RATE} ! \
{noise_stage}\
volume name=mic_input_gain volume={gain} ! \
tee name=t \
t. ! queue max-size-buffers={MIC_MAIN_QUEUE_MAX_BUFFERS} max-size-time={MIC_MAIN_QUEUE_MAX_TIME_NS} leaky=downstream ! \
@ -79,6 +91,7 @@ fn microphone_pipeline_desc(source_desc: &str, gain: f64, level_tap_enabled: boo
"{source_desc} ! \
audioconvert ! audioresample ! \
audio/x-raw,format=S16LE,channels={MIC_CHANNELS},rate={MIC_SAMPLE_RATE} ! \
{noise_stage}\
volume name=mic_input_gain volume={gain} ! \
queue max-size-buffers={MIC_MAIN_QUEUE_MAX_BUFFERS} max-size-time={MIC_MAIN_QUEUE_MAX_TIME_NS} leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers={MIC_APPSINK_MAX_BUFFERS} drop=true"
@ -86,6 +99,14 @@ fn microphone_pipeline_desc(source_desc: &str, gain: f64, level_tap_enabled: boo
}
}
fn microphone_noise_suppression_stage(enabled: bool) -> &'static str {
if enabled && gst::ElementFactory::find("webrtcdsp").is_some() {
"webrtcdsp echo-cancel=false noise-suppression=true noise-suppression-level=high high-pass-filter=true gain-control=false limiter=true ! "
} else {
""
}
}
fn buffer_duration_us(buf: &gst::BufferRef, bytes: usize) -> u64 {
let payload_duration_us = pcm_payload_duration_us(bytes);
buf.duration()
@ -149,12 +170,16 @@ fn split_audio_sample(base_pts_us: u64, data: &[u8], target_bytes: usize) -> Vec
if end == offset {
break;
}
packets.push_back(AudioPacket {
let duration_us = pcm_payload_duration_us(take);
let mut packet = AudioPacket {
id: 0,
pts: base_pts_us.saturating_add(pcm_payload_duration_us(offset)),
data: data[offset..end].to_vec(),
frame_duration_us: duration_us.min(u64::from(u32::MAX)) as u32,
..Default::default()
});
};
audio_transport::mark_packet_pcm_s16le(&mut packet);
packets.push_back(packet);
offset = end;
}
packets
@ -217,6 +242,10 @@ fn explicit_media_sources_required() -> bool {
bool_env_enabled(REQUIRE_EXPLICIT_MEDIA_SOURCES_ENV)
}
fn mic_noise_suppression_from_env() -> bool {
bool_env_enabled(MIC_NOISE_SUPPRESSION_ENV)
}
fn bool_env_enabled(name: &str) -> bool {
std::env::var(name).ok().is_some_and(|value| {
let value = value.trim();

View File

@ -11,11 +11,45 @@ impl MicrophoneCapture {
Self::new_with_source_and_env(None, false)
}
pub fn new_with_source_options(
source_override: Option<&str>,
codec: UpstreamAudioCodec,
noise_suppression: bool,
) -> Result<Self> {
Self::new_with_source_env_and_options(source_override, true, codec, noise_suppression)
}
pub fn new_default_source_options(
codec: UpstreamAudioCodec,
noise_suppression: bool,
) -> Result<Self> {
Self::new_with_source_env_and_options(None, false, codec, noise_suppression)
}
/// Keeps `new_with_source_and_env` explicit because it sits on microphone capture setup, where host audio stacks expose different source names and latency controls.
/// Inputs are the typed parameters; output is the return value or side effect.
fn new_with_source_and_env(
source_override: Option<&str>,
allow_env_source: bool,
) -> Result<Self> {
Self::new_with_source_env_and_options(
source_override,
allow_env_source,
crate::input::audio_codec::requested_upstream_audio_codec_from_env(),
mic_noise_suppression_from_env(),
)
}
/// Build a microphone capture pipeline with explicit uplink codec/noise choices.
///
/// Inputs: selected source, audio codec, and noise suppression flag. Output:
/// a live capture object. Why: live UI changes must rebuild only the mic
/// path while preserving the established camera/session transport.
fn new_with_source_env_and_options(
source_override: Option<&str>,
allow_env_source: bool,
codec: UpstreamAudioCodec,
noise_suppression: bool,
) -> Result<Self> {
gst::init().ok(); // idempotent
@ -43,7 +77,8 @@ impl MicrophoneCapture {
debug!("🎤 source: {source_desc}");
let gain = mic_gain_from_env();
let level_tap_path = mic_level_tap_path();
let desc = microphone_pipeline_desc(&source_desc, gain, level_tap_path.is_some());
let desc =
microphone_pipeline_desc(&source_desc, gain, level_tap_path.is_some(), noise_suppression);
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline");
let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap();
@ -97,19 +132,38 @@ impl MicrophoneCapture {
None
};
let audio_encoder = match codec {
UpstreamAudioCodec::PcmS16le => None,
UpstreamAudioCodec::Opus => {
match crate::input::audio_codec::OpusPacketEncoder::new() {
Ok(encoder) => {
info!("🎤 upstream microphone Opus encoder enabled");
Some(encoder)
}
Err(err) => {
warn!(
"🎤⚠️ Opus requested but unavailable ({err:#}); falling back to PCM"
);
None
}
}
}
};
Ok(Self {
pipeline,
sink,
level_tap_running,
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(),
pending_packets: Mutex::default(),
audio_encoder: Mutex::new(audio_encoder),
})
}
/// Blocking pull; call from an async wrapper
pub fn pull(&self) -> Option<AudioPacket> {
if let Some(packet) = self.pending_packets.lock().ok()?.pop_front() {
return Some(packet);
return Some(self.encode_for_transport(packet));
}
match self.sink.pull_sample() {
Ok(sample) => {
@ -170,12 +224,29 @@ impl MicrophoneCapture {
{
pending.extend(packets);
}
first_packet
first_packet.map(|packet| self.encode_for_transport(packet))
}
Err(_) => None,
}
}
fn encode_for_transport(&self, packet: AudioPacket) -> AudioPacket {
let Some(mut guard) = self.audio_encoder.lock().ok() else {
return packet;
};
let Some(encoder) = guard.as_mut() else {
return packet;
};
match encoder.encode_packet(packet.clone()) {
Ok(Some(encoded)) => encoded,
Ok(None) => packet,
Err(err) => {
warn!("🎤⚠️ Opus encode failed; sending PCM fallback for this packet: {err:#}");
packet
}
}
}
/// Resolve launcher-selected mic names while preserving Pulse catalog routing.
fn resolve_source_desc(fragment: &str) -> Option<String> {
if looks_like_pulse_source_name(fragment)
@ -294,4 +365,3 @@ impl MicrophoneCapture {
Self::pulse_source_desc(None)
}
}

View File

@ -1,5 +1,6 @@
// client/src/input/mod.rs
pub mod audio_codec;
pub mod camera; // stub for camera
pub mod inputs; // the aggregator that scans /dev/input and spawns sub-aggregators
pub mod keyboard; // existing keyboard aggregator logic (minus scanning)

View File

@ -401,7 +401,7 @@ impl Drop for MouseAggregator {
fn drop(&mut self) {
let _ = self.dev.ungrab();
let _ = self.tx.send(MouseReport {
data: [0; 8].into(),
data: [0; 4].into(),
});
}
}

View File

@ -55,6 +55,14 @@ fn build_relative_mouse(name: &str) -> Option<(evdev::uinput::VirtualDevice, evd
Some((vdev, dev))
}
/// Returns whether this run may emit virtual mouse events into the OS input stack.
///
/// The default is false because local test gates often share the user's active
/// desktop, where a uinput mouse can steal focus or move the pointer.
fn disruptive_input_tests_enabled() -> bool {
std::env::var("LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS").as_deref() == Ok("1")
}
struct StateHarness {
buttons: u8,
dx: i8,
@ -397,7 +405,12 @@ fn log_event_batch_tolerates_empty_and_populated_batches() {
#[test]
#[serial]
/// Exercises the live uinput read path only when an isolated test worker opts in.
fn process_events_emits_live_relative_packets_for_the_real_crate_path() {
if !disruptive_input_tests_enabled() {
return;
}
let Some((mut vdev, dev)) = build_relative_mouse("lesavka-unit-mouse-rel") else {
return;
};
@ -425,7 +438,12 @@ fn process_events_emits_live_relative_packets_for_the_real_crate_path() {
#[test]
#[serial]
/// Verifies disconnect recovery only when virtual-device events are safe to emit.
fn process_events_handles_disconnected_virtual_devices_without_panicking() {
if !disruptive_input_tests_enabled() {
return;
}
let Some((vdev, dev)) = build_relative_mouse("lesavka-unit-mouse-err") else {
return;
};

View File

@ -163,6 +163,19 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
"LESAVKA_CAM_CODEC".to_string(),
state.webcam_transport.env_value().to_string(),
);
envs.insert(
"LESAVKA_UPLINK_AUDIO_CODEC".to_string(),
state.upstream_audio_transport.env_value().to_string(),
);
envs.insert(
"LESAVKA_MIC_NOISE_SUPPRESSION".to_string(),
if state.mic_noise_suppression {
"1"
} else {
"0"
}
.to_string(),
);
if matches!(state.view_mode, ViewMode::Unified) {
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
}

View File

@ -314,6 +314,14 @@ impl LauncherState {
self.webcam_transport = transport;
}
pub fn select_upstream_audio_transport(&mut self, transport: UpstreamAudioTransport) {
self.upstream_audio_transport = transport;
}
pub fn set_mic_noise_suppression(&mut self, enabled: bool) {
self.mic_noise_suppression = enabled;
}
pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec<CameraMode> {
self.devices
.camera

View File

@ -1,7 +1,7 @@
impl LauncherState {
pub fn status_line(&self) -> String {
format!(
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} camera_transport={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} camera_transport={} mic={} mic_transport={} noise_suppression={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
self.server_available,
match self.routing {
InputRouting::Local => "local",
@ -29,6 +29,8 @@ impl LauncherState {
.unwrap_or_else(|| "default".to_string()),
self.webcam_transport.label(),
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
self.upstream_audio_transport.label(),
self.mic_noise_suppression,
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
self.channels.camera,
self.channels.microphone,

View File

@ -82,6 +82,61 @@ impl WebcamTransport {
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpstreamAudioTransport {
#[default]
Opus,
Pcm,
}
impl UpstreamAudioTransport {
/// Return the stable GTK row id for upstream microphone transport.
#[must_use]
pub const fn as_id(self) -> &'static str {
match self {
Self::Opus => "opus",
Self::Pcm => "pcm",
}
}
/// 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.
pub fn from_id(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"opus" | "compressed" => Some(Self::Opus),
"pcm" | "raw" | "s16le" | "uncompressed" => Some(Self::Pcm),
_ => None,
}
}
/// Return the environment/control value consumed by the relay child.
#[must_use]
pub const fn env_value(self) -> &'static str {
self.as_id()
}
/// Return a short label displayed in the launcher dropdown.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Opus => "Opus",
Self::Pcm => "PCM",
}
}
/// Convert the UI enum into the shared transport enum.
#[must_use]
pub const fn as_common_codec(self) -> lesavka_common::audio_transport::UpstreamAudioCodec {
match self {
Self::Opus => lesavka_common::audio_transport::UpstreamAudioCodec::Opus,
Self::Pcm => lesavka_common::audio_transport::UpstreamAudioCodec::PcmS16le,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ViewMode {
Unified,

View File

@ -277,6 +277,8 @@ pub struct LauncherState {
pub devices: DeviceSelection,
pub camera_quality: Option<CameraMode>,
pub webcam_transport: WebcamTransport,
pub upstream_audio_transport: UpstreamAudioTransport,
pub mic_noise_suppression: bool,
pub channels: ChannelSelection,
pub audio_gain_percent: u32,
pub mic_gain_percent: u32,
@ -315,6 +317,8 @@ impl Default for LauncherState {
devices: DeviceSelection::default(),
camera_quality: None,
webcam_transport: WebcamTransport::default(),
upstream_audio_transport: UpstreamAudioTransport::default(),
mic_noise_suppression: false,
channels: ChannelSelection::default(),
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,

View File

@ -1,5 +1,5 @@
use super::*;
use crate::launcher::state::WebcamTransport;
use crate::launcher::state::{UpstreamAudioTransport, WebcamTransport};
use serial_test::serial;
#[cfg(not(coverage))]
@ -163,6 +163,14 @@ fn runtime_env_vars_emit_selected_controls() {
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string()));
assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string()));
assert_eq!(envs.get("LESAVKA_CAM_CODEC"), Some(&"hevc".to_string()));
assert_eq!(
envs.get("LESAVKA_UPLINK_AUDIO_CODEC"),
Some(&"opus".to_string())
);
assert_eq!(
envs.get("LESAVKA_MIC_NOISE_SUPPRESSION"),
Some(&"0".to_string())
);
assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string()));
assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string()));
assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string()));
@ -269,6 +277,31 @@ fn runtime_env_vars_emit_selected_webcam_transport() {
);
}
#[test]
fn runtime_env_vars_emit_selected_audio_transport_and_noise_mode() {
let mut state = LauncherState::new();
assert_eq!(
runtime_env_vars(&state).get("LESAVKA_UPLINK_AUDIO_CODEC"),
Some(&"opus".to_string())
);
assert_eq!(
runtime_env_vars(&state).get("LESAVKA_MIC_NOISE_SUPPRESSION"),
Some(&"0".to_string())
);
state.select_upstream_audio_transport(UpstreamAudioTransport::Pcm);
state.set_mic_noise_suppression(true);
let envs = runtime_env_vars(&state);
assert_eq!(
envs.get("LESAVKA_UPLINK_AUDIO_CODEC"),
Some(&"pcm".to_string())
);
assert_eq!(
envs.get("LESAVKA_MIC_NOISE_SUPPRESSION"),
Some(&"1".to_string())
);
}
#[test]
fn runtime_env_vars_do_not_disable_breakout_video_windows() {
let mut state = LauncherState::new();

View File

@ -2,7 +2,10 @@ use super::*;
use crate::launcher::{
devices::{CameraMode, DeviceCatalog},
preview::PreviewBinding,
state::{BreakoutSizePreset, LauncherState, PreviewSourceSize, WebcamTransport},
state::{
BreakoutSizePreset, LauncherState, PreviewSourceSize, UpstreamAudioTransport,
WebcamTransport,
},
ui_components::build_launcher_view,
};
use crate::uplink_telemetry::UpstreamStreamTelemetry;
@ -420,6 +423,26 @@ fn webcam_transport_combo_tracks_selected_upstream_codec() {
);
}
#[test]
fn audio_transport_model_defaults_to_compressed_with_raw_fallback() {
assert_eq!(
UpstreamAudioTransport::default(),
UpstreamAudioTransport::Opus
);
assert_eq!(UpstreamAudioTransport::Opus.as_id(), "opus");
assert_eq!(UpstreamAudioTransport::Opus.label(), "Opus");
assert_eq!(UpstreamAudioTransport::Pcm.as_id(), "pcm");
assert_eq!(UpstreamAudioTransport::Pcm.label(), "PCM");
assert_eq!(
UpstreamAudioTransport::from_id("raw"),
Some(UpstreamAudioTransport::Pcm)
);
assert_eq!(
UpstreamAudioTransport::from_id("compressed"),
Some(UpstreamAudioTransport::Opus)
);
}
#[gtk::test]
#[serial]
fn diagnostics_and_log_popouts_install_native_window_chrome() {
@ -571,7 +594,7 @@ fn uac_chip_uses_live_microphone_flow_not_only_server_caps() {
assert_eq!(
recovery_uac_health(&state, false, None),
(StatusLightState::Live, "Ready".to_string())
(StatusLightState::Live, "Opus".to_string())
);
assert_eq!(
recovery_uac_health(&state, true, None),
@ -589,7 +612,13 @@ fn uac_chip_uses_live_microphone_flow_not_only_server_caps() {
};
assert_eq!(
recovery_uac_health(&state, true, Some(&healthy)),
(StatusLightState::Live, "Flowing".to_string())
(StatusLightState::Live, "Opus".to_string())
);
state.select_upstream_audio_transport(UpstreamAudioTransport::Pcm);
assert_eq!(
recovery_uac_health(&state, true, Some(&healthy)),
(StatusLightState::Live, "PCM".to_string())
);
state.set_microphone_channel_enabled(false);

View File

@ -20,7 +20,7 @@ use {
super::state::{
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,
DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
MAX_MIC_GAIN_PERCENT, UpstreamSyncStatus, WebcamTransport,
MAX_MIC_GAIN_PERCENT, UpstreamAudioTransport, UpstreamSyncStatus, WebcamTransport,
},
super::ui_components::{
ConsoleLogLevel, build_launcher_view, sync_camera_quality_combo, sync_input_device_combo,

View File

@ -128,6 +128,43 @@
});
}
{
let state = Rc::clone(&state);
let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc);
let toggle = widgets.noise_suppression_toggle.clone();
toggle.connect_toggled(move |toggle| {
let enabled = toggle.is_active();
if let Ok(mut state) = state.try_borrow_mut() {
state.set_mic_noise_suppression(enabled);
}
let label = if enabled {
"Noise cancellation enabled"
} else {
"Noise cancellation disabled"
};
if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) {
if child_proc.borrow().is_some() {
apply_live_media_device_change(
&state_snapshot,
&widgets,
&child_proc,
"Microphone noise suppression",
);
} else {
widgets.status_label.set_text(&format!(
"{label} for the next relay launch."
));
}
refresh_launcher_ui(
&widgets,
&state_snapshot,
child_proc.borrow().is_some(),
);
}
});
}
{
let state = Rc::clone(&state);
let widgets = widgets.clone();

View File

@ -80,6 +80,39 @@
});
}
{
let state = Rc::clone(&state);
let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc);
let audio_combo = widgets.upstream_audio_transport_combo.clone();
let audio_combo_read = audio_combo.clone();
audio_combo.connect_changed(move |_| {
let selected = audio_combo_read
.active_id()
.as_deref()
.and_then(UpstreamAudioTransport::from_id)
.unwrap_or_default();
state
.borrow_mut()
.select_upstream_audio_transport(selected);
let relay_live = child_proc.borrow().is_some();
if relay_live {
apply_live_media_device_change(
&state.borrow(),
&widgets,
&child_proc,
"Microphone transport",
);
} else {
widgets.status_label.set_text(&format!(
"Upstream microphone transport set to {} for the next relay launch.",
selected.label()
));
}
refresh_launcher_ui(&widgets, &state.borrow(), relay_live);
});
}
{
let state = Rc::clone(&state);
let widgets = widgets.clone();

View File

@ -9,7 +9,7 @@ use super::{
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
state::{
BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset,
FeedSourceChoice, FeedSourcePreset, LauncherState, WebcamTransport,
FeedSourceChoice, FeedSourcePreset, LauncherState, UpstreamAudioTransport, WebcamTransport,
},
};
@ -60,6 +60,7 @@ pub fn build_launcher_view(
mouse_combo,
camera_channel_toggle,
microphone_channel_toggle,
noise_suppression_toggle,
audio_channel_toggle,
audio_gain_scale,
audio_gain_value,
@ -73,6 +74,7 @@ pub fn build_launcher_view(
camera_preview_frame,
camera_preview,
webcam_transport_combo,
upstream_audio_transport_combo,
camera_mirror_button,
camera_mirror_revealer,
camera_status,

View File

@ -133,6 +133,7 @@
mouse_combo: mouse_combo.clone(),
camera_channel_toggle: camera_channel_toggle.clone(),
microphone_channel_toggle: microphone_channel_toggle.clone(),
noise_suppression_toggle: noise_suppression_toggle.clone(),
audio_channel_toggle: audio_channel_toggle.clone(),
power_auto_button: power_auto_button.clone(),
power_on_button: power_on_button.clone(),
@ -157,6 +158,7 @@
camera_test_button: camera_test_button.clone(),
camera_preview_stack: camera_preview_stack.clone(),
webcam_transport_combo: webcam_transport_combo.clone(),
upstream_audio_transport_combo: upstream_audio_transport_combo.clone(),
camera_mirror_button: camera_mirror_button.clone(),
camera_mirror_revealer: camera_mirror_revealer.clone(),
microphone_test_button: microphone_test_button.clone(),
@ -193,6 +195,7 @@
camera_preview_frame,
camera_preview,
webcam_transport_combo,
upstream_audio_transport_combo,
camera_mirror_button,
camera_status,
},

View File

@ -30,6 +30,7 @@ struct DeviceControlsContext {
mouse_combo: gtk::ComboBoxText,
camera_channel_toggle: gtk::ToggleButton,
microphone_channel_toggle: gtk::ToggleButton,
noise_suppression_toggle: gtk::ToggleButton,
audio_channel_toggle: gtk::ToggleButton,
audio_gain_scale: gtk::Scale,
audio_gain_value: gtk::Label,
@ -43,6 +44,7 @@ struct DeviceControlsContext {
camera_preview_frame: gtk::AspectFrame,
camera_preview: gtk::Picture,
webcam_transport_combo: gtk::ComboBoxText,
upstream_audio_transport_combo: gtk::ComboBoxText,
camera_mirror_button: gtk::ToggleButton,
camera_mirror_revealer: gtk::Revealer,
camera_status: gtk::Label,

View File

@ -92,7 +92,17 @@
microphone_channel_toggle.set_tooltip_text(Some("Send mic during relay."));
microphone_channel_toggle.add_css_class("pill-toggle");
microphone_channel_toggle.add_css_class("media-toggle");
stabilize_button(&microphone_channel_toggle, 92);
stabilize_button(&microphone_channel_toggle, 46);
let noise_suppression_toggle = gtk::ToggleButton::with_label("🧹");
noise_suppression_toggle.set_active(state.mic_noise_suppression);
noise_suppression_toggle.set_tooltip_text(Some(if state.mic_noise_suppression {
"Noise cancellation is on for the upstream microphone."
} else {
"Noise cancellation is off; upstream microphone is raw aside from gain."
}));
noise_suppression_toggle.add_css_class("pill-toggle");
noise_suppression_toggle.add_css_class("media-toggle");
stabilize_button(&noise_suppression_toggle, 42);
let audio_gain_adjustment = gtk::Adjustment::new(
f64::from(state.audio_gain_percent),
@ -176,10 +186,13 @@
microphone_combo.set_hexpand(true);
microphone_selectors.append(&microphone_combo);
microphone_selectors.append(&mic_gain_scale);
let microphone_toggle_group = gtk::Box::new(gtk::Orientation::Horizontal, 4);
microphone_toggle_group.append(&microphone_channel_toggle);
microphone_toggle_group.append(&noise_suppression_toggle);
attach_device_control_row(
&media_grid,
2,
&microphone_channel_toggle,
&microphone_toggle_group,
&microphone_selectors,
&microphone_test_button,
);
@ -196,7 +209,38 @@
devices_body.append(&media_group);
staging_row.append(&devices_panel);
let (preview_panel, preview_body) = build_panel("Upstream Media");
let upstream_transport_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
upstream_transport_row.set_halign(gtk::Align::End);
let webcam_transport_combo = gtk::ComboBoxText::new();
webcam_transport_combo.add_css_class("compact-combo");
for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] {
webcam_transport_combo.append(Some(transport.as_id()), transport.label());
}
webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id()));
webcam_transport_combo.set_sensitive(true);
webcam_transport_combo.set_size_request(98, -1);
webcam_transport_combo.set_tooltip_text(Some(
"Upstream webcam transport for the next relay connection. HEVC is the low-latency default; MJPEG is the calibrated fallback.",
));
let upstream_audio_transport_combo = gtk::ComboBoxText::new();
upstream_audio_transport_combo.add_css_class("compact-combo");
for transport in [UpstreamAudioTransport::Opus, UpstreamAudioTransport::Pcm] {
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_transport_row.append(&webcam_transport_combo);
upstream_transport_row.append(&upstream_audio_transport_combo);
let (preview_panel, preview_body) =
build_panel_with_action("Upstream Media", Some(upstream_transport_row.upcast_ref()));
preview_panel.set_hexpand(true);
preview_panel.set_vexpand(true);
preview_panel.set_valign(gtk::Align::Fill);
@ -328,19 +372,7 @@
camera_preview_stack.add_named(&camera_preview_overlay, Some("live"));
camera_preview_stack.set_visible_child_name("idle");
camera_preview_shell.append(&camera_preview_stack);
let webcam_transport_combo = gtk::ComboBoxText::new();
webcam_transport_combo.add_css_class("compact-combo");
for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] {
webcam_transport_combo.append(Some(transport.as_id()), transport.label());
}
webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id()));
webcam_transport_combo.set_sensitive(true);
webcam_transport_combo.set_size_request(112, -1);
webcam_transport_combo.set_tooltip_text(Some(
"Upstream webcam transport for the next relay connection. HEVC is the low-latency default; MJPEG is the calibrated fallback.",
));
let webcam_group =
build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref()));
let webcam_group = build_subgroup("Webcam Preview");
webcam_group.set_hexpand(true);
webcam_group.set_vexpand(true);
webcam_group.set_valign(gtk::Align::Fill);
@ -383,6 +415,7 @@
mouse_combo,
camera_channel_toggle,
microphone_channel_toggle,
noise_suppression_toggle,
audio_channel_toggle,
audio_gain_scale,
audio_gain_value,
@ -396,6 +429,7 @@
camera_preview_frame,
camera_preview,
webcam_transport_combo,
upstream_audio_transport_combo,
camera_mirror_button,
camera_mirror_revealer,
camera_status,

View File

@ -50,7 +50,7 @@
let (routing_chip, routing_light, routing_value) =
build_status_chip_with_light("Inputs", "Local");
let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown");
let (usb_chip, usb_light, usb_value) = build_status_chip_with_light("USB", "Unknown");
let (usb_chip, usb_light, usb_value) = build_status_chip_with_light("HID", "Unknown");
let (uac_chip, uac_light, uac_value) = build_status_chip_with_light("UAC", "Unknown");
let (uvc_chip, uvc_light, uvc_value) = build_status_chip_with_light("UVC", "Unknown");
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");

View File

@ -253,11 +253,11 @@ fn human_audio_node_label(value: &str) -> String {
}
fn shorten_label(value: &str) -> String {
shorten_label_with_limit(value, 20)
shorten_label_with_limit(value, 34)
}
fn shorten_input_label(value: &str) -> String {
shorten_label_with_limit(value, 22)
shorten_label_with_limit(value, 34)
}
fn shorten_label_with_limit(value: &str, max: usize) -> String {

View File

@ -149,6 +149,10 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
border-radius: 999px;
background: rgba(91, 179, 162, 0.88);
}
scale slider {
min-width: 14px;
min-height: 14px;
}
entry.server-entry {
min-height: 38px;
}

View File

@ -140,6 +140,7 @@ pub struct LauncherWidgets {
pub mouse_combo: gtk::ComboBoxText,
pub camera_channel_toggle: gtk::ToggleButton,
pub microphone_channel_toggle: gtk::ToggleButton,
pub noise_suppression_toggle: gtk::ToggleButton,
pub audio_channel_toggle: gtk::ToggleButton,
pub power_auto_button: gtk::Button,
pub power_on_button: gtk::Button,
@ -164,6 +165,7 @@ pub struct LauncherWidgets {
pub camera_test_button: gtk::Button,
pub camera_preview_stack: gtk::Stack,
pub webcam_transport_combo: gtk::ComboBoxText,
pub upstream_audio_transport_combo: gtk::ComboBoxText,
pub camera_mirror_button: gtk::ToggleButton,
pub camera_mirror_revealer: gtk::Revealer,
pub microphone_test_button: gtk::Button,
@ -187,6 +189,7 @@ pub struct DeviceStageWidgets {
pub camera_preview_frame: gtk::AspectFrame,
pub camera_preview: gtk::Picture,
pub webcam_transport_combo: gtk::ComboBoxText,
pub upstream_audio_transport_combo: gtk::ComboBoxText,
pub camera_mirror_button: gtk::ToggleButton,
pub camera_status: gtk::Label,
}

View File

@ -145,7 +145,7 @@ pub fn write_mic_gain_request(path: &Path, gain_percent: u32) -> Result<()> {
pub fn write_media_control_request(path: &Path, state: &LauncherState) -> Result<()> {
crate::live_media_control::write_media_control_request(
path,
crate::live_media_control::MediaControlState::with_devices(
crate::live_media_control::MediaControlState::with_devices_and_audio(
state.channels.camera,
state.channels.microphone,
state.channels.audio,
@ -153,6 +153,8 @@ pub fn write_media_control_request(path: &Path, state: &LauncherState) -> Result
state.camera_quality.map(|mode| mode.id()),
state.devices.microphone.clone(),
state.devices.speaker.clone(),
state.upstream_audio_transport.as_common_codec(),
state.mic_noise_suppression,
),
)?;
Ok(())

View File

@ -153,13 +153,19 @@ fn recovery_uac_health(
if state.server_microphone.is_none() {
return (StatusLightState::Caution, "Unknown".to_string());
}
let codec = state.upstream_audio_transport.label().to_string();
if !relay_live {
return (StatusLightState::Live, "Ready".to_string());
return (StatusLightState::Live, codec);
}
if !state.channels.microphone {
return (StatusLightState::Idle, "Paused".to_string());
}
media_stream_health(stream, MediaStreamKind::Microphone)
let (health, label) = media_stream_health(stream, MediaStreamKind::Microphone);
if matches!(health, StatusLightState::Live) {
(health, codec)
} else {
(health, label)
}
}
/// Summarize whether the UVC camera function is advertised with the selected upstream codec.

View File

@ -98,7 +98,15 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
);
set_status_light(&widgets.summary.uac_light, uac_state);
widgets.summary.uac_value.set_text(&uac_value);
widgets.summary.uac_value.set_tooltip_text(Some(&uac_value));
widgets.summary.uac_value.set_tooltip_text(Some(&format!(
"Upstream microphone transport: {}. {}",
state.upstream_audio_transport.label(),
if state.mic_noise_suppression {
"Noise suppression is enabled before transport."
} else {
"Raw microphone audio is sent before transport."
}
)));
let (uvc_state, uvc_value) = recovery_uvc_health(
state,
relay_live,
@ -108,7 +116,10 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
);
set_status_light(&widgets.summary.uvc_light, uvc_state);
widgets.summary.uvc_value.set_text(&uvc_value);
widgets.summary.uvc_value.set_tooltip_text(Some(&uvc_value));
widgets.summary.uvc_value.set_tooltip_text(Some(&format!(
"Upstream webcam transport: {}. Server calibration is profile-specific.",
state.webcam_transport.label()
)));
let power_detail = if state.server_available {
capture_power_detail(&state.capture_power)
@ -138,6 +149,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.microphone_channel_toggle
.set_active(state.channels.microphone);
}
if widgets.noise_suppression_toggle.is_active() != state.mic_noise_suppression {
widgets
.noise_suppression_toggle
.set_active(state.mic_noise_suppression);
}
if widgets.audio_channel_toggle.is_active() != state.channels.audio {
widgets
.audio_channel_toggle
@ -197,6 +213,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets
.microphone_channel_toggle
.set_sensitive(!relay_live || state.devices.microphone.is_some());
widgets
.noise_suppression_toggle
.set_sensitive(state.channels.microphone);
widgets
.audio_channel_toggle
.set_sensitive(!relay_live || state.devices.speaker.is_some());
@ -211,6 +230,13 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets
.microphone_channel_toggle
.set_tooltip_text(Some(media_toggle_tooltip));
widgets
.noise_suppression_toggle
.set_tooltip_text(Some(if state.mic_noise_suppression {
"Noise cancellation is on. Toggle to send raw microphone audio aside from gain."
} else {
"Noise cancellation is off. Toggle to suppress steady room, street, and fan noise."
}));
widgets
.audio_channel_toggle
.set_tooltip_text(Some(media_toggle_tooltip));
@ -243,6 +269,26 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
} else {
"Choose HEVC for low-latency upstream video or MJPEG as the calibrated fallback for the next relay launch."
}));
if widgets
.upstream_audio_transport_combo
.active_id()
.as_deref()
!= Some(state.upstream_audio_transport.as_id())
{
widgets
.upstream_audio_transport_combo
.set_active_id(Some(state.upstream_audio_transport.as_id()));
}
widgets
.upstream_audio_transport_combo
.set_sensitive(state.channels.microphone);
widgets
.upstream_audio_transport_combo
.set_tooltip_text(Some(if relay_live {
"Changing upstream audio transport restarts the microphone path; Opus compresses, PCM is the fallback."
} else {
"Choose Opus for compressed upstream audio or PCM as the known-good fallback."
}));
widgets.input_toggle_button.set_label(match state.routing {
InputRouting::Remote => "Route Local",
InputRouting::Local => "Route Remote",

View File

@ -8,6 +8,7 @@ use std::{
};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use lesavka_common::audio_transport::{UpstreamAudioCodec, parse_upstream_audio_codec};
pub const MEDIA_CONTROL_ENV: &str = "LESAVKA_MEDIA_CONTROL";
pub const DEFAULT_MEDIA_CONTROL_PATH: &str = "/tmp/lesavka-media.control";
@ -19,6 +20,54 @@ pub(crate) enum MediaDeviceChoice {
Selected(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum MediaAudioCodecChoice {
Inherit,
Selected(UpstreamAudioCodec),
}
impl MediaAudioCodecChoice {
#[must_use]
pub fn selected(codec: UpstreamAudioCodec) -> Self {
Self::Selected(codec)
}
#[must_use]
pub fn resolve(&self, fallback: UpstreamAudioCodec) -> UpstreamAudioCodec {
match self {
Self::Inherit => fallback,
Self::Selected(codec) => *codec,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum MediaNoiseSuppressionChoice {
Inherit,
Enabled,
Disabled,
}
impl MediaNoiseSuppressionChoice {
#[must_use]
pub fn selected(enabled: bool) -> Self {
if enabled {
Self::Enabled
} else {
Self::Disabled
}
}
#[must_use]
pub fn resolve(&self, fallback: bool) -> bool {
match self {
Self::Inherit => fallback,
Self::Enabled => true,
Self::Disabled => false,
}
}
}
impl MediaDeviceChoice {
#[must_use]
pub fn from_selection(selection: Option<String>) -> Self {
@ -49,6 +98,8 @@ pub(crate) struct MediaControlState {
pub camera_profile: MediaDeviceChoice,
pub microphone_source: MediaDeviceChoice,
pub audio_sink: MediaDeviceChoice,
pub audio_codec: MediaAudioCodecChoice,
pub noise_suppression: MediaNoiseSuppressionChoice,
}
impl MediaControlState {
@ -62,6 +113,8 @@ impl MediaControlState {
camera_profile: MediaDeviceChoice::Inherit,
microphone_source: MediaDeviceChoice::Inherit,
audio_sink: MediaDeviceChoice::Inherit,
audio_codec: MediaAudioCodecChoice::Inherit,
noise_suppression: MediaNoiseSuppressionChoice::Inherit,
}
}
@ -83,6 +136,36 @@ impl MediaControlState {
camera_profile: MediaDeviceChoice::from_selection(camera_profile),
microphone_source: MediaDeviceChoice::from_selection(microphone_source),
audio_sink: MediaDeviceChoice::from_selection(audio_sink),
audio_codec: MediaAudioCodecChoice::Inherit,
noise_suppression: MediaNoiseSuppressionChoice::Inherit,
}
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn with_devices_and_audio(
camera: bool,
microphone: bool,
audio: bool,
camera_source: Option<String>,
camera_profile: Option<String>,
microphone_source: Option<String>,
audio_sink: Option<String>,
audio_codec: UpstreamAudioCodec,
noise_suppression: bool,
) -> Self {
Self {
audio_codec: MediaAudioCodecChoice::selected(audio_codec),
noise_suppression: MediaNoiseSuppressionChoice::selected(noise_suppression),
..Self::with_devices(
camera,
microphone,
audio,
camera_source,
camera_profile,
microphone_source,
audio_sink,
)
}
}
}
@ -134,7 +217,7 @@ pub(crate) fn write_media_control_request(
fs::write(
path,
format!(
"camera={} microphone={} audio={} camera_source={} camera_profile={} microphone_source={} audio_sink={} nonce={}\n",
"camera={} microphone={} audio={} camera_source={} camera_profile={} microphone_source={} audio_sink={} audio_codec={} noise_suppression={} nonce={}\n",
bool_flag(state.camera),
bool_flag(state.microphone),
bool_flag(state.audio),
@ -142,6 +225,8 @@ pub(crate) fn write_media_control_request(
encode_choice(&state.camera_profile),
encode_choice(&state.microphone_source),
encode_choice(&state.audio_sink),
encode_audio_codec_choice(&state.audio_codec),
encode_noise_suppression_choice(&state.noise_suppression),
control_request_nonce(),
),
)
@ -156,6 +241,8 @@ fn parse_media_control_state(raw: &str) -> Option<MediaControlState> {
let mut camera_profile = MediaDeviceChoice::Inherit;
let mut microphone_source = MediaDeviceChoice::Inherit;
let mut audio_sink = MediaDeviceChoice::Inherit;
let mut audio_codec = MediaAudioCodecChoice::Inherit;
let mut noise_suppression = MediaNoiseSuppressionChoice::Inherit;
for token in raw.split_ascii_whitespace() {
let Some((key, value)) = token.split_once('=') else {
continue;
@ -170,6 +257,12 @@ fn parse_media_control_state(raw: &str) -> Option<MediaControlState> {
microphone_source = parse_choice(value)?;
}
"audio_sink" | "speaker_sink" | "audio_sink_b64" => audio_sink = parse_choice(value)?,
"audio_codec" | "uplink_audio_codec" => {
audio_codec = parse_audio_codec_choice(value)?;
}
"noise_suppression" | "mic_noise_suppression" => {
noise_suppression = parse_noise_suppression_choice(value)?;
}
_ => {}
}
}
@ -181,6 +274,8 @@ fn parse_media_control_state(raw: &str) -> Option<MediaControlState> {
camera_profile,
microphone_source,
audio_sink,
audio_codec,
noise_suppression,
})
}
@ -212,6 +307,36 @@ fn parse_choice(value: &str) -> Option<MediaDeviceChoice> {
Some(MediaDeviceChoice::from_selection(Some(value.to_string())))
}
fn encode_audio_codec_choice(choice: &MediaAudioCodecChoice) -> &'static str {
match choice {
MediaAudioCodecChoice::Inherit => "inherit",
MediaAudioCodecChoice::Selected(codec) => codec.as_id(),
}
}
fn parse_audio_codec_choice(value: &str) -> Option<MediaAudioCodecChoice> {
let value = value.trim();
if value.eq_ignore_ascii_case("inherit") || value.is_empty() {
return Some(MediaAudioCodecChoice::Inherit);
}
parse_upstream_audio_codec(value).map(MediaAudioCodecChoice::Selected)
}
fn encode_noise_suppression_choice(choice: &MediaNoiseSuppressionChoice) -> &'static str {
match choice {
MediaNoiseSuppressionChoice::Inherit => "inherit",
MediaNoiseSuppressionChoice::Enabled => "1",
MediaNoiseSuppressionChoice::Disabled => "0",
}
}
fn parse_noise_suppression_choice(value: &str) -> Option<MediaNoiseSuppressionChoice> {
if value.trim().eq_ignore_ascii_case("inherit") || value.trim().is_empty() {
return Some(MediaNoiseSuppressionChoice::Inherit);
}
parse_bool_flag(value).map(MediaNoiseSuppressionChoice::selected)
}
/// Keeps `parse_bool_flag` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
fn parse_bool_flag(value: &str) -> Option<bool> {

View File

@ -25,6 +25,12 @@ async fn main() -> Result<()> {
#[cfg(not(test))]
let args = env::args().skip(1).collect::<Vec<_>>();
#[cfg(not(test))]
if args.iter().any(|arg| arg == "--version" || arg == "-V") {
println!("lesavka-client {}", lesavka_client::VERSION);
return Ok(());
}
let headless = env::var("LESAVKA_HEADLESS").is_ok();
if !headless {
ensure_runtime_dir();

View File

@ -11,6 +11,12 @@ use std::process::Command;
use tracing::{debug, error, info, warn};
/// Pick the first H.264 decoder that can be built on this client.
///
/// Inputs: optional `LESAVKA_H264_DECODER` override and
/// `LESAVKA_H264_DECODER_PREFERENCE=hardware|software`. Output: a decoder
/// element name. Why: breakout windows should benefit from NVIDIA proprietary
/// decode when it is present, while keeping VAAPI/V4L2 and CPU routes usable
/// for open-source-driver machines and debugging.
fn pick_h264_decoder() -> String {
if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") {
let name = raw.trim();
@ -22,16 +28,7 @@ fn pick_h264_decoder() -> String {
}
}
for name in [
"avdec_h264",
"openh264dec",
"nvh264dec",
"nvh264sldec",
"vah264dec",
"vaapih264dec",
"v4l2h264dec",
"v4l2slh264dec",
] {
for name in h264_decoder_preference_order() {
if buildable_decoder(name) {
return name.to_string();
}
@ -40,6 +37,43 @@ fn pick_h264_decoder() -> String {
"decodebin".to_string()
}
/// Return automatic decoder candidates in the same order breakout windows use.
///
/// Inputs: `LESAVKA_H264_DECODER_PREFERENCE`, if set. Output: ordered decoder
/// element names. Why: include-based tests need to protect the same hardware
/// and software route order as the launcher preview path.
fn h264_decoder_preference_order() -> Vec<&'static str> {
const HARDWARE: &[&str] = &[
"nvh264dec",
"nvh264sldec",
"vah264dec",
"vaapih264dec",
"v4l2h264dec",
"v4l2slh264dec",
];
const SOFTWARE: &[&str] = &["avdec_h264", "openh264dec"];
let prefer_software = std::env::var("LESAVKA_H264_DECODER_PREFERENCE")
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"software" | "sw" | "cpu"
)
})
.unwrap_or(false);
let mut candidates = Vec::with_capacity(HARDWARE.len() + SOFTWARE.len());
if prefer_software {
candidates.extend_from_slice(SOFTWARE);
candidates.extend_from_slice(HARDWARE);
} else {
candidates.extend_from_slice(HARDWARE);
candidates.extend_from_slice(SOFTWARE);
}
candidates
}
#[cfg(coverage)]
/// Probe decoder availability, with a coverage hook for fallback behavior.
fn buildable_decoder(name: &str) -> bool {

View File

@ -185,6 +185,7 @@ fn build_probe_bundle(
audio,
audio_sample_rate: 48_000,
audio_channels: 2,
audio_encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32,
video_width: camera.width,
video_height: camera.height,
video_fps: camera.fps,

View File

@ -5,11 +5,13 @@ use gstreamer as gst;
/// Pick the client-side H.264 decoder in a predictable preference order.
///
/// Inputs: none, though operators may override the choice with
/// `LESAVKA_H264_DECODER=<element>`.
/// `LESAVKA_H264_DECODER=<element>` or bias automatic fallback order with
/// `LESAVKA_H264_DECODER_PREFERENCE=hardware|software`.
/// Outputs: the chosen decoder element name, or `decodebin` as a last-resort
/// fallback when no explicit decoder is present.
/// Why: `decodebin` is flexible, but a stable preference order makes decode
/// behavior easier to reason about and compare in diagnostics.
/// Why: Lesavka should use GPU decode on NVIDIA/VAAPI/V4L2-capable clients
/// when possible, while keeping an explicit CPU route for open-source driver
/// comparisons and driver debugging.
#[must_use]
pub fn pick_h264_decoder() -> String {
if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") {
@ -22,16 +24,7 @@ pub fn pick_h264_decoder() -> String {
}
}
for name in [
"avdec_h264",
"openh264dec",
"nvh264dec",
"nvh264sldec",
"vah264dec",
"vaapih264dec",
"v4l2h264dec",
"v4l2slh264dec",
] {
for name in h264_decoder_preference_order() {
if buildable_decoder(name) {
return name.to_string();
}
@ -40,6 +33,44 @@ pub fn pick_h264_decoder() -> String {
"decodebin".to_string()
}
/// Return automatic H.264 decoder candidates in selection order.
///
/// Inputs: `LESAVKA_H264_DECODER_PREFERENCE`, if set. Output: ordered decoder
/// element names. Why: tests and diagnostics need to prove both proprietary
/// NVIDIA and open-source VAAPI/V4L2 routes stay available before CPU fallback.
#[must_use]
pub fn h264_decoder_preference_order() -> Vec<&'static str> {
const HARDWARE: &[&str] = &[
"nvh264dec",
"nvh264sldec",
"vah264dec",
"vaapih264dec",
"v4l2h264dec",
"v4l2slh264dec",
];
const SOFTWARE: &[&str] = &["avdec_h264", "openh264dec"];
let prefer_software = std::env::var("LESAVKA_H264_DECODER_PREFERENCE")
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"software" | "sw" | "cpu"
)
})
.unwrap_or(false);
let mut candidates = Vec::with_capacity(HARDWARE.len() + SOFTWARE.len());
if prefer_software {
candidates.extend_from_slice(SOFTWARE);
candidates.extend_from_slice(HARDWARE);
} else {
candidates.extend_from_slice(HARDWARE);
candidates.extend_from_slice(SOFTWARE);
}
candidates
}
fn buildable_decoder(name: &str) -> bool {
#[cfg(coverage)]
if std::env::var("TEST_FAIL_GST_INIT").is_ok() {

View File

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

View File

@ -33,6 +33,11 @@ message VideoPacket {
uint32 client_queue_depth = 15;
uint32 client_queue_age_ms = 16;
}
enum AudioEncoding {
AUDIO_UNSPECIFIED = 0;
PCM_S16LE = 1;
OPUS = 2;
}
message AudioPacket {
uint32 id = 1;
uint64 pts = 2;
@ -44,6 +49,12 @@ message AudioPacket {
uint64 client_send_pts_us = 6;
uint32 client_queue_depth = 7;
uint32 client_queue_age_ms = 8;
// Explicit payload encoding. Older/unstamped packets are interpreted as
// PCM_S16LE so the current stable raw-audio path stays backward compatible.
AudioEncoding encoding = 9;
uint32 sample_rate = 10;
uint32 channels = 11;
uint32 frame_duration_us = 12;
}
message UpstreamMediaBundle {
@ -61,6 +72,9 @@ message UpstreamMediaBundle {
uint32 video_width = 9;
uint32 video_height = 10;
uint32 video_fps = 11;
// Batch-level audio encoding. This mirrors the packet encoding so receivers
// can route a bundle before inspecting every audio payload.
AudioEncoding audio_encoding = 12;
}
message OutputDelayProbeRequest {

View File

@ -0,0 +1,294 @@
//! Shared audio-transport metadata helpers.
//!
//! Why: upstream audio is currently raw PCM, but Opus experiments need an
//! explicit schema and sizing model so client, server, and tests agree before
//! any production switch is made.
use crate::lesavka::{AudioEncoding, AudioPacket, UpstreamMediaBundle};
pub const PCM_SAMPLE_RATE: u32 = 48_000;
pub const PCM_CHANNELS: u32 = 2;
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;
/// Operator-facing upstream audio transport choice.
///
/// Inputs: a UI/env/control-file token. Output: either the stable raw PCM route
/// or the compressed Opus route. Why: both ends need the same vocabulary so
/// Opus can be tried without weakening the known-good PCM fallback path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpstreamAudioCodec {
PcmS16le,
Opus,
}
impl UpstreamAudioCodec {
/// Return the compact id stored in control files and environment values.
#[must_use]
pub const fn as_id(self) -> &'static str {
match self {
Self::PcmS16le => "pcm",
Self::Opus => "opus",
}
}
/// Return a short label suitable for the launcher UI and diagnostics.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::PcmS16le => "PCM",
Self::Opus => "Opus",
}
}
/// Return the packet profile this transport should emit.
#[must_use]
pub const fn profile(self) -> AudioTransportProfile {
match self {
Self::PcmS16le => AudioTransportProfile::pcm_s16le(),
Self::Opus => AudioTransportProfile::opus_voice(),
}
}
}
/// Parse a launcher/env codec token into an upstream audio transport.
#[must_use]
pub fn parse_upstream_audio_codec(raw: &str) -> Option<UpstreamAudioCodec> {
match raw.trim().to_ascii_lowercase().as_str() {
"pcm" | "pcm_s16le" | "s16le" | "raw" | "uncompressed" => {
Some(UpstreamAudioCodec::PcmS16le)
}
"opus" | "compressed" | "voice" => Some(UpstreamAudioCodec::Opus),
_ => None,
}
}
/// A normalized description of the payload carried by `AudioPacket::data`.
///
/// Inputs are codec metadata resolved from a packet or bundle. Outputs are the
/// exact framing values the transport should preserve. Why: the Opus path must
/// compare bandwidth and timing without weakening the known-good PCM fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AudioTransportProfile {
pub encoding: AudioEncoding,
pub sample_rate: u32,
pub channels: u32,
pub frame_duration_us: u32,
pub target_bitrate_bps: Option<u32>,
}
impl AudioTransportProfile {
/// Return the stable raw PCM profile used by today's upstream microphone path.
#[must_use]
pub const fn pcm_s16le() -> Self {
Self {
encoding: AudioEncoding::PcmS16le,
sample_rate: PCM_SAMPLE_RATE,
channels: PCM_CHANNELS,
frame_duration_us: PCM_FRAME_DURATION_US,
target_bitrate_bps: None,
}
}
/// Return the first Opus profile Lesavka should test for upstream audio.
///
/// Inputs: none. Outputs: a 48 kHz stereo, 20 ms, 64 kbps voice-oriented
/// profile. Why: Opus always runs internally at 48 kHz, and 20 ms frames
/// keep latency bounded while still giving the codec enough lookahead.
#[must_use]
pub const fn opus_voice() -> Self {
Self {
encoding: AudioEncoding::Opus,
sample_rate: OPUS_SAMPLE_RATE,
channels: OPUS_CHANNELS,
frame_duration_us: OPUS_FRAME_DURATION_US,
target_bitrate_bps: Some(OPUS_DEFAULT_BITRATE_BPS),
}
}
/// Estimate the payload budget for one frame in this transport profile.
///
/// Inputs: profile fields. Output: bytes per frame. Why: performance tests
/// need a stable, codec-independent way to compare PCM debt against Opus.
#[must_use]
pub fn expected_payload_bytes(self) -> u32 {
match self.encoding {
AudioEncoding::PcmS16le | AudioEncoding::AudioUnspecified => {
let samples = u64::from(self.sample_rate)
.saturating_mul(u64::from(self.frame_duration_us))
/ 1_000_000;
samples
.saturating_mul(u64::from(self.channels))
.saturating_mul(2)
.min(u64::from(u32::MAX)) as u32
}
AudioEncoding::Opus => {
let bits = u64::from(self.target_bitrate_bps.unwrap_or(OPUS_DEFAULT_BITRATE_BPS))
.saturating_mul(u64::from(self.frame_duration_us))
/ 1_000_000;
bits.div_ceil(8).min(u64::from(u32::MAX)) as u32
}
}
}
}
/// Interpret unset/legacy audio encoding metadata as the stable PCM transport.
///
/// Inputs: raw proto enum integer. Output: normalized encoding. Why:
/// `AUDIO_UNSPECIFIED` exists for wire compatibility but should not leave
/// callers guessing whether a payload is safe for the raw UAC sink.
#[must_use]
pub fn normalize_audio_encoding(raw: i32) -> AudioEncoding {
match AudioEncoding::try_from(raw).unwrap_or(AudioEncoding::AudioUnspecified) {
AudioEncoding::AudioUnspecified | AudioEncoding::PcmS16le => AudioEncoding::PcmS16le,
AudioEncoding::Opus => AudioEncoding::Opus,
}
}
/// Resolve the packet-level transport profile.
///
/// Inputs: a protobuf audio packet. Output: a normalized profile. Why: callers
/// should not need to duplicate the legacy-PCM fallback or Opus defaults.
#[must_use]
pub fn packet_audio_profile(packet: &AudioPacket) -> AudioTransportProfile {
match normalize_audio_encoding(packet.encoding) {
AudioEncoding::Opus => AudioTransportProfile {
encoding: AudioEncoding::Opus,
sample_rate: nonzero_or(packet.sample_rate, OPUS_SAMPLE_RATE),
channels: nonzero_or(packet.channels, OPUS_CHANNELS),
frame_duration_us: nonzero_or(packet.frame_duration_us, OPUS_FRAME_DURATION_US),
target_bitrate_bps: Some(OPUS_DEFAULT_BITRATE_BPS),
},
AudioEncoding::PcmS16le | AudioEncoding::AudioUnspecified => AudioTransportProfile {
encoding: AudioEncoding::PcmS16le,
sample_rate: nonzero_or(packet.sample_rate, PCM_SAMPLE_RATE),
channels: nonzero_or(packet.channels, PCM_CHANNELS),
frame_duration_us: nonzero_or(packet.frame_duration_us, PCM_FRAME_DURATION_US),
target_bitrate_bps: None,
},
}
}
/// Resolve the bundle-level transport profile from the bundle marker or packets.
///
/// Inputs: an upstream media bundle. Output: the audio profile receivers should
/// expect. Why: the server needs to route the whole A/V bundle without splitting
/// audio away from video just to discover its codec.
#[must_use]
pub fn bundle_audio_profile(bundle: &UpstreamMediaBundle) -> AudioTransportProfile {
let raw = if bundle.audio_encoding != 0 {
bundle.audio_encoding
} else {
bundle
.audio
.first()
.map(|packet| packet.encoding)
.unwrap_or_default()
};
match normalize_audio_encoding(raw) {
AudioEncoding::Opus => AudioTransportProfile {
encoding: AudioEncoding::Opus,
sample_rate: nonzero_or(bundle.audio_sample_rate, OPUS_SAMPLE_RATE),
channels: nonzero_or(bundle.audio_channels, OPUS_CHANNELS),
frame_duration_us: bundle
.audio
.first()
.map(|packet| nonzero_or(packet.frame_duration_us, OPUS_FRAME_DURATION_US))
.unwrap_or(OPUS_FRAME_DURATION_US),
target_bitrate_bps: Some(OPUS_DEFAULT_BITRATE_BPS),
},
AudioEncoding::PcmS16le | AudioEncoding::AudioUnspecified => AudioTransportProfile {
encoding: AudioEncoding::PcmS16le,
sample_rate: nonzero_or(bundle.audio_sample_rate, PCM_SAMPLE_RATE),
channels: nonzero_or(bundle.audio_channels, PCM_CHANNELS),
frame_duration_us: bundle
.audio
.first()
.map(|packet| nonzero_or(packet.frame_duration_us, PCM_FRAME_DURATION_US))
.unwrap_or(PCM_FRAME_DURATION_US),
target_bitrate_bps: None,
},
}
}
/// Stamp a packet as raw PCM with the current stable microphone framing.
///
/// Inputs: mutable packet. Output: side-effect only. Why: explicit metadata lets
/// future Opus tests distinguish today's known-good path from experimental
/// compressed audio without relying on payload-size guesses.
pub fn mark_packet_pcm_s16le(packet: &mut AudioPacket) {
packet.encoding = AudioEncoding::PcmS16le as i32;
packet.sample_rate = PCM_SAMPLE_RATE;
packet.channels = PCM_CHANNELS;
if packet.frame_duration_us == 0 {
packet.frame_duration_us = PCM_FRAME_DURATION_US;
}
}
/// Stamp a packet as Opus with the current low-latency voice framing.
///
/// Inputs: mutable packet. Output: side-effect only. Why: receivers must never
/// infer compressed audio from payload size, especially when PCM fallback can
/// intentionally coexist with Opus during startup.
pub fn mark_packet_opus(packet: &mut AudioPacket) {
packet.encoding = AudioEncoding::Opus as i32;
packet.sample_rate = OPUS_SAMPLE_RATE;
packet.channels = OPUS_CHANNELS;
if packet.frame_duration_us == 0 {
packet.frame_duration_us = OPUS_FRAME_DURATION_US;
}
}
/// Stamp a bundle with normalized audio metadata.
///
/// Inputs: mutable bundle. Output: side-effect only. Why: the server should be
/// able to choose the PCM or future Opus handoff path from the bundle envelope.
pub fn mark_bundle_audio_profile(bundle: &mut UpstreamMediaBundle, profile: AudioTransportProfile) {
bundle.audio_encoding = profile.encoding as i32;
bundle.audio_sample_rate = profile.sample_rate;
bundle.audio_channels = profile.channels;
}
/// True when a packet can be handed directly to the raw UAC appsrc.
#[must_use]
pub fn packet_is_raw_pcm_s16le(packet: &AudioPacket) -> bool {
packet_audio_profile(packet).encoding == AudioEncoding::PcmS16le
}
const fn nonzero_or(value: u32, fallback: u32) -> u32 {
if value == 0 { fallback } else { value }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pcm_and_opus_profiles_keep_expected_frame_budgets() {
assert_eq!(
AudioTransportProfile::pcm_s16le().expected_payload_bytes(),
3840
);
assert_eq!(
AudioTransportProfile::opus_voice().expected_payload_bytes(),
160
);
assert!(
AudioTransportProfile::opus_voice().expected_payload_bytes() * 20
<= AudioTransportProfile::pcm_s16le().expected_payload_bytes(),
"Opus profile should be dramatically smaller than raw PCM"
);
}
#[test]
fn legacy_packets_normalize_to_pcm() {
let packet = AudioPacket::default();
let profile = packet_audio_profile(&packet);
assert_eq!(profile.encoding, AudioEncoding::PcmS16le);
assert_eq!(profile.sample_rate, 48_000);
assert_eq!(profile.channels, 2);
}
}

View File

@ -2,6 +2,7 @@
// Re-export the code generated by build.rs (lesavka.rs, relay.rs, etc.)
// common/src/lib.rs
pub mod audio_transport;
pub mod cli;
pub mod eye_source;
pub mod hid;

View File

@ -10,9 +10,11 @@ and makes it readable only by the installing account. The client installer first
from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the installed
`ca.crt`, `client.crt`, and `client.key` for HTTPS/mTLS relay connections.
| `LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS` | test-only opt-in for virtual keyboard/mouse tests that can emit events into the active desktop; use only on isolated CI workers or lab sessions |
| `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config |
| `LESAVKA_ALLOW_GADGET_RESET` | permits installer/recovery flows to reset the USB gadget when a hard rebuild is otherwise required; by itself it must not force a live attached-host reset |
| `LESAVKA_ALLOW_INSECURE` | client transport override; permits non-local `http://` relay URLs only for lab/debug use |
| `LESAVKA_ALLOW_LAB_HARDWARE_TESTS` | CI/lab safety opt-in before running bare-metal gates that may touch Theia/Tethys, UVC/UAC devices, or virtual HID input |
| `LESAVKA_ALSA_DEV` | server hardware/device override |
| `LESAVKA_ATTACH_WRITE_UDC` | server hardware/device override |
| `LESAVKA_AUDIO_AUTO_RECOVER_AFTER` | client media capture/playback override |
@ -77,6 +79,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_CLIENT_RCT_MAX_AGE_MS` | manual client-to-RCT transport probe freshness limit; maximum client-origin-to-RCT-observed p95 age including clock uncertainty, defaults to `1000` |
| `LESAVKA_CLIENT_RCT_MIN_PAIRS` | manual client-to-RCT transport probe evidence floor; minimum paired flash/tone events before freshness can pass, defaults to `13` |
| `LESAVKA_CLIENT_RCT_MODE` | manual client-to-RCT transport probe expected RCT UVC mode in `WIDTHxHEIGHT@FPS` form, or `auto` to read the current gadget profile; defaults to `auto` and does not reconfigure the gadget |
| `LESAVKA_CLIENT_RCT_PROBE_CMD` | bare-metal lab gate command override for the client-to-RCT transport probe; use only on isolated lab workers |
| `LESAVKA_CLIENT_RCT_REQUIRE_SMOOTHNESS` | manual client-to-RCT transport probe gate toggle; when `1`, cadence hiccups fail the transport summary instead of reporting warnings |
| `LESAVKA_CLIENT_RCT_SYNC_SAMPLE_INTERVAL_SECONDS` | manual client-to-RCT transport probe introspection interval; controls how often the harness samples server `upstream-sync` state while the client-origin probe is live |
| `LESAVKA_CLIENT_RCT_UVC_FRAME_META_LOG_REMOTE` | manual client-to-RCT transport probe optional artifact path; fetches a pre-enabled server `LESAVKA_UVC_FRAME_META_LOG_PATH` JSONL and summarizes UVC spool-boundary timing |
@ -84,6 +87,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `PROBE_EVENT_WIDTH_CODES` | manual client-to-RCT transport probe identity sequence; defaults to unique codes `1..16` so final RCT observations can be joined to client-origin timeline events after startup drops |
| `LESAVKA_CLIENT_RELAYCTL_BIN_SRC` | test/build contract variable; not runtime operator config |
| `LESAVKA_CLIENT_VIDEO_SUPPORT_SRC` | test/build contract variable; not runtime operator config |
| `LESAVKA_CI_PROFILE` | Jenkins/CI profile selector; `safe` runs non-disruptive gates, `daily` wraps primary-branch daily gates, and `lab` is reserved for isolated bare-metal probes |
| `LESAVKA_CLIPBOARD_CHORD` | input routing/clipboard override |
| `LESAVKA_CLIPBOARD_CMD` | input routing/clipboard override |
| `LESAVKA_CLIPBOARD_DEBOUNCE_MS` | input routing/clipboard override |
@ -98,6 +102,8 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_DECODER_PROBE_WAIT_SECONDS` | manual probe override |
| `LESAVKA_DETACH_CLEAR_UDC` | server hardware/device override |
| `LESAVKA_DEV_MODE` | document near use before promoting to operator config |
| `LESAVKA_DAILY_ALLOW_NON_PRIMARY` | CI override that lets `daily_master_gate.sh` run on non-`master`/`main` branches for debugging the daily profile |
| `LESAVKA_DAILY_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for the daily master gate profile |
| `LESAVKA_DISABLE_UAC` | document near use before promoting to operator config |
| `LESAVKA_DISABLE_UVC` | document near use before promoting to operator config |
| `LESAVKA_DISABLE_VIDEO_RENDER` | eye preview/video transport override |
@ -122,7 +128,8 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_GADGET_FORCE_CYCLE` | server hardware/device override |
| `LESAVKA_GADGET_SYSFS_ROOT` | server hardware/device override |
| `LESAVKA_GIT_SHA` | runtime/install/session override |
| `LESAVKA_H264_DECODER` | eye preview/video transport override |
| `LESAVKA_H264_DECODER` | eye preview/video transport override; names an explicit GStreamer decoder such as `nvh264dec`, `v4l2h264dec`, `avdec_h264`, or `decodebin` |
| `LESAVKA_H264_DECODER_PREFERENCE` | eye preview/video transport override; `hardware`/unset prefers NVIDIA, VAAPI, and V4L2 decode before CPU fallback, while `software`/`cpu` keeps software first for driver comparison |
| `LESAVKA_HDMI_CONNECTOR` | server hardware/device override |
| `LESAVKA_HDMI_DRIVER` | server hardware/device override |
| `LESAVKA_HDMI_FBDEV` | server hardware/device override |
@ -171,6 +178,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_LAUNCHER_PARENT_START_TICKS` | launcher UI/runtime override |
| `LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL` | launcher UI/runtime override |
| `LESAVKA_LAUNCHER_WINDOW_TITLE` | launcher UI/runtime override |
| `LESAVKA_LAB_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for the opt-in bare-metal lab gate profile |
| `LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS` | input routing/clipboard override |
| `LESAVKA_LIVE_MODIFIER_DELAY_MS` | input routing/clipboard override |
| `LESAVKA_MAX_SPEED` | document near use before promoting to operator config |
@ -217,6 +225,9 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_REPO_URL` | runtime/install/session override |
| `LESAVKA_REQUIRE_TLS` | server security override; require TLS credentials before binding public relay service |
| `LESAVKA_RGBA` | document near use before promoting to operator config |
| `LESAVKA_RUN_CLIENT_RCT_PROBE` | bare-metal lab gate toggle for running the client-to-RCT transport probe; defaults off so shared CI never contacts lab hardware |
| `LESAVKA_RUN_SERVER_RCT_MATRIX` | bare-metal lab gate toggle for running the server-to-RCT mode matrix; defaults off because it can reconfigure attached UVC/UAC hardware |
| `LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE` | bare-metal lab gate toggle for deterministic downstream video guard tests; defaults on inside the lab gate |
| `LESAVKA_SERVER_ADDR` | runtime/install/session override |
| `LESAVKA_SERVER_BIND_ADDR` | server bind address override; defaults to `0.0.0.0:50051` |
| `LESAVKA_INSTALL_SERVER_BIND_ADDR` | installer override; sets the persisted default server bind address in `/etc/lesavka/server.env` |
@ -366,7 +377,8 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
These entries are intentionally concise because most are manual lab or CI harness controls. The detailed behavior lives in the scripts and source that consume them; this table keeps every `LESAVKA_*` knob discoverable for operators and the hygiene gate.
| `LESAVKA_CAM_EMIT_UI_PROFILE` | client camera/profile negotiation override; used by launcher or lab probes to control emitted capture profile metadata |
| `LESAVKA_CALIBRATION_PROFILE` | server calibration profile override (`mjpeg` or `hevc`); selects profile-specific server-to-RCT offset maps |
| `LESAVKA_CALIBRATION_AUDIO_CODEC` | server calibration profile override for upstream microphone transport (`pcm` or `opus`); keeps Opus delay experiments separate from known-good PCM baselines |
| `LESAVKA_CALIBRATION_PROFILE` | server calibration profile override (`mjpeg`, `hevc`, or combined `camera+audio` such as `hevc+opus`); selects profile-specific server-to-RCT offset maps |
| `LESAVKA_CAM_LOCK_TO_SERVER_PROFILE` | client camera/profile negotiation override; used by launcher or lab probes to control emitted capture profile metadata |
| `LESAVKA_CAM_HEVC_KBIT` | client HEVC camera encoder bitrate in kbit/s; defaults to `3000` for latency-first upstream transport |
| `LESAVKA_CLIENT_RCT_START_DELAY_SECONDS` | manual client-to-RCT transport probe start delay; lets installed server changes settle before capture starts |
@ -407,6 +419,7 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH` | server installer diagnostic path; persists `LESAVKA_UVC_FRAME_META_LOG_PATH`, defaults to `/tmp/lesavka-uvc-frame-meta.jsonl` for optional client-to-RCT spool-boundary fetches |
| `LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US` | installer default override; seeds server calibration env files with known lab-measured output-path offsets |
| `LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US` | installer default override; seeds server calibration env files with known lab-measured output-path offsets |
| `LESAVKA_INSTALL_UPLINK_AUDIO_CODEC` | server installer microphone transport profile seed (`pcm` or `opus`); defaults to `pcm` so reruns preserve the established golden calibration unless Opus is explicitly selected |
| `LESAVKA_LEGACY_SPLIT_UPLINK` | runtime/install/session override; document near use before promoting to broader operator config |
| `LESAVKA_LOCAL_HEVC_BUNDLE_AUDIT_JSON` | local HEVC bundle audit output path; receives the generated JSON manifest for outgoing synthetic HEVC+audio bundles |
| `LESAVKA_LOCAL_HEVC_BUNDLE_AUDIT_OUTPUT_DIR` | local HEVC bundle audit artifact directory, defaults to a timestamped `/tmp/lesavka-local-hevc-bundle-audit-*` path |
@ -514,6 +527,7 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_SERVER_RC_TUNE_MIN_PAIRS` | manual server-to-RCT mode-matrix probe override; used to tune, confirm, or summarize server-generated UVC/UAC output against the RC capture target |
| `LESAVKA_SERVER_RC_VERBOSE_PROBES` | manual server-to-RCT mode-matrix probe override; used to tune, confirm, or summarize server-generated UVC/UAC output against the RC capture target |
| `LESAVKA_SERVER_RC_WAIT_TETHYS_READY` | manual server-to-RCT mode-matrix probe override; used to tune, confirm, or summarize server-generated UVC/UAC output against the RC capture target |
| `LESAVKA_SERVER_RCT_MATRIX_CMD` | bare-metal lab gate command override for the server-to-RCT mode matrix; use only on isolated lab workers |
| `LESAVKA_SERVER_REPO` | manual probe/server connection override used to resolve the target Lesavka server and mode under test |
| `LESAVKA_SERVER_SCHEME` | manual probe/server connection override used to resolve the target Lesavka server and mode under test |
| `LESAVKA_STIMULUS_BROWSER_KIOSK` | manual browser-stimulus probe override; controls local review browser behavior for mirrored upstream A/V tests |
@ -546,13 +560,23 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_UAC_APP_MAX_BUFFERS` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability |
| `LESAVKA_UAC_APP_MAX_BYTES` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability |
| `LESAVKA_UAC_APP_MAX_TIME_NS` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability |
| `LESAVKA_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_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 |
| `LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined HEVC camera plus Opus microphone playout delay map by `WIDTHxHEIGHT@FPS`; forked from the PCM map until lab calibration replaces it |
| `LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US` | server combined HEVC camera plus Opus microphone scalar playout delay in microseconds; used when no mode-specific value is present |
| `LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined HEVC camera plus PCM microphone playout delay map by `WIDTHxHEIGHT@FPS`; preserves the current golden PCM baseline |
| `LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US` | server combined HEVC camera plus PCM microphone scalar playout delay in microseconds; used when no mode-specific value is present |
| `LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US` | server HEVC-ingress video playout delay map by `WIDTHxHEIGHT@FPS`; includes decode/re-emit path calibration |
| `LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US` | server HEVC-ingress scalar video playout delay in microseconds; used when no mode-specific value is present |
| `LESAVKA_UPSTREAM_MJPEG_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server MJPEG-ingress audio playout delay map by `WIDTHxHEIGHT@FPS`; preserves the calibrated MJPEG transport profile |
| `LESAVKA_UPSTREAM_MJPEG_AUDIO_PLAYOUT_OFFSET_US` | server MJPEG-ingress scalar audio playout delay in microseconds; used when no mode-specific value is present |
| `LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined MJPEG camera plus Opus microphone playout delay map by `WIDTHxHEIGHT@FPS`; forked from the PCM map until lab calibration replaces it |
| `LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US` | server combined MJPEG camera plus Opus microphone scalar playout delay in microseconds; used when no mode-specific value is present |
| `LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server combined MJPEG camera plus PCM microphone playout delay map by `WIDTHxHEIGHT@FPS`; preserves the current golden PCM baseline |
| `LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US` | server combined MJPEG camera plus PCM microphone scalar playout delay in microseconds; used when no mode-specific value is present |
| `LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_MODE_OFFSETS_US` | server MJPEG-ingress video playout delay map by `WIDTHxHEIGHT@FPS`; preserves the calibrated MJPEG transport profile |
| `LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_OFFSET_US` | server MJPEG-ingress scalar video playout delay in microseconds; used when no mode-specific value is present |
| `LESAVKA_UPSTREAM_BLIND_HEAL` | server upstream media blind-healer tuning knob; adjusts cautious runtime offset correction when telemetry indicates persistent skew |
@ -572,8 +596,10 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_UPSTREAM_AUTO_HEAL_AFTER_MS` | client live bundled-upstream startup heal delay; defaults to `3000`ms before issuing the safe audio-epoch recovery |
| `LESAVKA_UPSTREAM_SOURCE_LEAD_CAP_MS` | server upstream media timing override; bounds live source lead or playout behavior while tuning client-to-server transport |
| `LESAVKA_UVC_CONFIGFS_BASE` | server UVC gadget mode/configfs override used by runtime reconfiguration and hardware-in-the-loop probes |
| `LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT` | server HEVC-to-MJPEG corruption guard threshold; flat decoded MJPEG payloads with one byte at or above this percentage are frozen out, default `92` |
| `LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP` | server HEVC-to-MJPEG corruption guard toggle; defaults on so suspicious decoded frame collapses freeze the last good MJPEG frame |
| `LESAVKA_UVC_HEVC_JPEG_QUALITY` | server HEVC-to-MJPEG UVC bridge JPEG quality; defaults to `72` to lower UVC payload pressure while keeping RCT output compatible |
| `LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES` | server HEVC-to-MJPEG corruption guard threshold; decoded MJPEG payloads with fewer distinct payload bytes are treated as flat grey/black damage, default `12` |
| `LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES` | server HEVC-to-MJPEG corruption guard baseline; decoded MJPEG frames smaller than this do not become freeze references |
| `LESAVKA_UVC_HEVC_SIZE_DROP_PCT` | server HEVC-to-MJPEG corruption guard threshold; frames below this percentage of the last good reference are frozen out |
| `LESAVKA_UVC_MODE` | server UVC gadget mode/configfs override used by runtime reconfiguration and hardware-in-the-loop probes |

View File

@ -0,0 +1,39 @@
# Opus Transport Testing
Lesavka does not use SIP, RTP, or SIPp for upstream microphone media. Upstream
audio is bundled with webcam video in `UpstreamMediaBundle`, and the launcher can
select either compressed `Opus` or raw `S16LE` PCM.
The Opus profile protects the production migration seam:
- The protobuf schema can explicitly label `AudioPacket` payloads as `PCM_S16LE`
or `OPUS`.
- Legacy or unstamped audio still normalizes to `PCM_S16LE`.
- Client microphone packets stamp PCM metadata before optional Opus encoding.
- Bundled media carries batch-level audio encoding metadata beside video.
- The server decodes Opus back to raw PCM before handing audio to the UAC sink.
- If Opus encode/decode is unavailable, Lesavka falls back to or drops safely
instead of treating compressed bytes as PCM.
- Performance tests compare the 20 ms Opus budget against raw PCM byte pressure.
Current Opus model:
- sample rate: `48000`
- channels: `2`
- frame duration: `20000us`
- target bitrate: `64000bps`
- expected payload: about `160 bytes` per 20 ms frame
The current route is:
`raw mic capture -> optional webrtcdsp -> opusenc -> gRPC bundle -> opusdec -> raw UAC`
PCM remains available as:
`raw mic capture -> optional webrtcdsp -> gRPC bundle -> raw UAC`
Any recalibration should record both the upstream video codec (`HEVC`/`MJPEG`)
and upstream audio codec (`Opus`/`PCM`) so old PCM baselines stay distinguishable.
Server calibration profiles use `camera+audio` names such as `hevc+pcm` and
`hevc+opus`. The PCM maps are the known-good factory values; the Opus maps start
as a separate namespace and should be replaced only after Theia/RCT calibration.

228
scripts/ci/baremetal_lab_gate.sh Executable file
View File

@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Run opt-in hardware/lab gates that are unsafe for shared CI desktops.
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
REPORT_DIR="${ROOT_DIR}/target/baremetal-lab-gate"
SUMMARY_JSON="${REPORT_DIR}/summary.json"
SUMMARY_TXT="${REPORT_DIR}/summary.txt"
METRICS_FILE="${REPORT_DIR}/metrics.prom"
RUN_LOG="${REPORT_DIR}/baremetal-lab-gate.log"
PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-}
PUSHGATEWAY_JOB=${LESAVKA_LAB_GATE_PUSHGATEWAY_JOB:-lesavka-baremetal-lab-gate}
mkdir -p "${REPORT_DIR}"
cd "${ROOT_DIR}"
branch=${BRANCH_NAME:-${GIT_BRANCH:-}}
if [[ -z "${branch}" ]]; then
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
fi
commit=${GIT_COMMIT:-}
if [[ -z "${commit}" ]]; then
commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
fi
build_url=${BUILD_URL:-}
log() {
printf '%s\n' "$*" | tee -a "${RUN_LOG}"
}
: >"${RUN_LOG}"
status=0
outcome=ok
detail="ran configured bare-metal lab gates"
steps_jsonl="${REPORT_DIR}/steps.jsonl"
: >"${steps_jsonl}"
start_seconds=$(date +%s)
record_step() {
local name="$1"
local result="$2"
local note="$3"
python3 - "${steps_jsonl}" "${name}" "${result}" "${note}" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
entry = {'name': sys.argv[2], 'result': sys.argv[3], 'note': sys.argv[4]}
with path.open('a', encoding='utf-8') as fh:
fh.write(json.dumps(entry, sort_keys=True) + '\n')
PY
}
run_shell_step() {
local name="$1"
local command="$2"
log "==> ${name}"
set +e
bash -lc "${command}" 2>&1 | tee -a "${RUN_LOG}"
local step_status=${PIPESTATUS[0]}
set -e
if [[ "${step_status}" -eq 0 ]]; then
record_step "${name}" ok "completed"
else
record_step "${name}" failed "exit ${step_status}"
status="${step_status}"
fi
}
run_step() {
local name="$1"
shift
log "==> ${name}"
set +e
"$@" 2>&1 | tee -a "${RUN_LOG}"
local step_status=${PIPESTATUS[0]}
set -e
if [[ "${step_status}" -eq 0 ]]; then
record_step "${name}" ok "completed"
else
record_step "${name}" failed "exit ${step_status}"
status="${step_status}"
fi
}
if [[ "${LESAVKA_ALLOW_LAB_HARDWARE_TESTS:-0}" != "1" ]]; then
outcome=skipped
detail="bare-metal lab gates require LESAVKA_ALLOW_LAB_HARDWARE_TESTS=1"
log "Skipping bare-metal lab gates."
log "These gates may use Theia/Tethys, UVC/UAC devices, or virtual HID input."
log "Run only on an isolated worker/session with LESAVKA_ALLOW_LAB_HARDWARE_TESTS=1."
else
if [[ "${LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE:-1}" == "1" ]]; then
run_step "video_downstream_gate" scripts/ci/video_downstream_gate.sh
else
record_step "video_downstream_gate" skipped "LESAVKA_RUN_VIDEO_DOWNSTREAM_GATE!=1"
fi
if [[ "${LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS:-0}" == "1" ]]; then
run_step "input_transport_gate" scripts/ci/input_transport_gate.sh
else
record_step "input_transport_gate" skipped "LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS!=1"
log "Skipping disruptive input transport gate; set LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS=1 on an isolated worker."
fi
if [[ "${LESAVKA_RUN_SERVER_RCT_MATRIX:-0}" == "1" ]]; then
server_rct_matrix_cmd=${LESAVKA_SERVER_RCT_MATRIX_CMD:-"${ROOT_DIR}/scripts/manual/run_server_to_rc_mode_matrix.sh"}
run_shell_step "server_to_rct_matrix" "${server_rct_matrix_cmd}"
else
record_step "server_to_rct_matrix" skipped "LESAVKA_RUN_SERVER_RCT_MATRIX!=1"
fi
if [[ "${LESAVKA_RUN_CLIENT_RCT_PROBE:-0}" == "1" ]]; then
client_rct_probe_cmd=${LESAVKA_CLIENT_RCT_PROBE_CMD:-"${ROOT_DIR}/scripts/manual/run_client_to_rct_transport_probe.sh"}
run_shell_step "client_to_rct_transport_probe" "${client_rct_probe_cmd}"
else
record_step "client_to_rct_transport_probe" skipped "LESAVKA_RUN_CLIENT_RCT_PROBE!=1"
fi
if [[ "${status}" -ne 0 ]]; then
outcome=failed
detail="one or more bare-metal lab gates failed"
fi
fi
duration_seconds=$(($(date +%s) - start_seconds))
python3 - \
"${SUMMARY_JSON}" \
"${SUMMARY_TXT}" \
"${METRICS_FILE}" \
"${steps_jsonl}" \
"${branch}" \
"${commit}" \
"${build_url}" \
"${outcome}" \
"${status}" \
"${duration_seconds}" \
"${detail}" <<'PY'
import json
import pathlib
import sys
from datetime import datetime, timezone
summary_path = pathlib.Path(sys.argv[1])
text_path = pathlib.Path(sys.argv[2])
metrics_path = pathlib.Path(sys.argv[3])
steps_path = pathlib.Path(sys.argv[4])
branch = sys.argv[5]
commit = sys.argv[6]
build_url = sys.argv[7]
outcome = sys.argv[8]
status = int(sys.argv[9])
duration_seconds = int(sys.argv[10])
detail = sys.argv[11]
def esc(value: str) -> str:
return value.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')
steps = []
if steps_path.exists():
for line in steps_path.read_text(encoding='utf-8').splitlines():
if line.strip():
steps.append(json.loads(line))
summary = {
'suite': 'lesavka',
'profile': 'lab',
'branch': branch,
'commit': commit,
'build_url': build_url,
'outcome': outcome,
'exit_code': status,
'duration_seconds': duration_seconds,
'detail': detail,
'steps': steps,
'generated_at': datetime.now(timezone.utc).isoformat(),
}
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8')
text_path.write_text(
'\n'.join([
f'lesavka bare-metal lab gate: {outcome}',
f'branch: {branch}',
f'commit: {commit}',
f'duration: {duration_seconds}s',
f'detail: {detail}',
'steps:',
*[f"- {step['name']}: {step['result']} ({step['note']})" for step in steps],
]) + '\n',
encoding='utf-8',
)
labels = f'suite="lesavka",profile="lab",branch="{esc(branch)}",commit="{esc(commit)}"'
ok = 1 if outcome == 'ok' else 0
failed = 1 if outcome == 'failed' else 0
skipped = 1 if outcome == 'skipped' else 0
lines = [
'# HELP lesavka_ci_profile_last_run_success Whether the latest Lesavka CI profile run succeeded.',
'# TYPE lesavka_ci_profile_last_run_success gauge',
f'lesavka_ci_profile_last_run_success{{{labels}}} {ok}',
'# HELP lesavka_ci_profile_duration_seconds Duration of the latest Lesavka CI profile run.',
'# TYPE lesavka_ci_profile_duration_seconds gauge',
f'lesavka_ci_profile_duration_seconds{{{labels}}} {duration_seconds}',
'# HELP lesavka_ci_profile_runs Current profile run outcome.',
'# TYPE lesavka_ci_profile_runs gauge',
f'lesavka_ci_profile_runs{{{labels},status="ok"}} {ok}',
f'lesavka_ci_profile_runs{{{labels},status="failed"}} {failed}',
f'lesavka_ci_profile_runs{{{labels},status="skipped"}} {skipped}',
'# HELP lesavka_lab_gate_step_result Bare-metal lab gate step result.',
'# TYPE lesavka_lab_gate_step_result gauge',
]
for step in steps:
name = esc(step['name'])
for result in ['ok', 'failed', 'skipped']:
value = 1 if step['result'] == result else 0
lines.append(f'lesavka_lab_gate_step_result{{{labels},step="{name}",result="{result}"}} {value}')
metrics_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
PY
if [[ -n "${PUSHGATEWAY_URL}" ]]; then
curl --fail --silent --show-error \
--data-binary @"${METRICS_FILE}" \
"${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka" || status=$?
fi
exit "${status}"

136
scripts/ci/daily_master_gate.sh Executable file
View File

@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Run the safe daily gate for the current primary branch.
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
REPORT_DIR="${ROOT_DIR}/target/daily-master-gate"
SUMMARY_JSON="${REPORT_DIR}/summary.json"
SUMMARY_TXT="${REPORT_DIR}/summary.txt"
METRICS_FILE="${REPORT_DIR}/metrics.prom"
PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-}
PUSHGATEWAY_JOB=${LESAVKA_DAILY_GATE_PUSHGATEWAY_JOB:-lesavka-daily-master-gate}
mkdir -p "${REPORT_DIR}"
cd "${ROOT_DIR}"
branch=${BRANCH_NAME:-${GIT_BRANCH:-}}
if [[ -z "${branch}" ]]; then
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
fi
commit=${GIT_COMMIT:-}
if [[ -z "${commit}" ]]; then
commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
fi
build_url=${BUILD_URL:-}
is_primary_branch=0
case "${branch}" in
master|main|origin/master|origin/main|*/master|*/main)
is_primary_branch=1
;;
esac
status=0
outcome=ok
detail="ran safe platform quality gate"
start_seconds=$(date +%s)
if [[ "${is_primary_branch}" != "1" && "${LESAVKA_DAILY_ALLOW_NON_PRIMARY:-0}" != "1" ]]; then
outcome=skipped
detail="daily gate only evaluates master/main by default"
else
set +e
LESAVKA_CI_PROFILE=daily scripts/ci/platform_quality_gate.sh
status=$?
set -e
if [[ "${status}" -ne 0 ]]; then
outcome=failed
detail="safe platform quality gate failed"
fi
fi
duration_seconds=$(($(date +%s) - start_seconds))
python3 - \
"${SUMMARY_JSON}" \
"${SUMMARY_TXT}" \
"${METRICS_FILE}" \
"${branch}" \
"${commit}" \
"${build_url}" \
"${outcome}" \
"${status}" \
"${duration_seconds}" \
"${detail}" <<'PY'
import json
import pathlib
import sys
from datetime import datetime, timezone
summary_path = pathlib.Path(sys.argv[1])
text_path = pathlib.Path(sys.argv[2])
metrics_path = pathlib.Path(sys.argv[3])
branch = sys.argv[4]
commit = sys.argv[5]
build_url = sys.argv[6]
outcome = sys.argv[7]
status = int(sys.argv[8])
duration_seconds = int(sys.argv[9])
detail = sys.argv[10]
def esc(value: str) -> str:
return value.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')
summary = {
'suite': 'lesavka',
'profile': 'daily',
'branch': branch,
'commit': commit,
'build_url': build_url,
'outcome': outcome,
'exit_code': status,
'duration_seconds': duration_seconds,
'detail': detail,
'generated_at': datetime.now(timezone.utc).isoformat(),
}
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8')
text_path.write_text(
'\n'.join([
f'lesavka daily gate: {outcome}',
f'branch: {branch}',
f'commit: {commit}',
f'duration: {duration_seconds}s',
f'detail: {detail}',
]) + '\n',
encoding='utf-8',
)
labels = f'suite="lesavka",profile="daily",branch="{esc(branch)}",commit="{esc(commit)}"'
ok = 1 if outcome == 'ok' else 0
failed = 1 if outcome == 'failed' else 0
skipped = 1 if outcome == 'skipped' else 0
metrics_path.write_text(
'\n'.join([
'# HELP lesavka_ci_profile_last_run_success Whether the latest Lesavka CI profile run succeeded.',
'# TYPE lesavka_ci_profile_last_run_success gauge',
f'lesavka_ci_profile_last_run_success{{{labels}}} {ok}',
'# HELP lesavka_ci_profile_duration_seconds Duration of the latest Lesavka CI profile run.',
'# TYPE lesavka_ci_profile_duration_seconds gauge',
f'lesavka_ci_profile_duration_seconds{{{labels}}} {duration_seconds}',
'# HELP lesavka_ci_profile_runs Current profile run outcome.',
'# TYPE lesavka_ci_profile_runs gauge',
f'lesavka_ci_profile_runs{{{labels},status="ok"}} {ok}',
f'lesavka_ci_profile_runs{{{labels},status="failed"}} {failed}',
f'lesavka_ci_profile_runs{{{labels},status="skipped"}} {skipped}',
]) + '\n',
encoding='utf-8',
)
PY
if [[ -n "${PUSHGATEWAY_URL}" ]]; then
curl --fail --silent --show-error \
--data-binary @"${METRICS_FILE}" \
"${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka" || status=$?
fi
exit "${status}"

View File

@ -46,7 +46,7 @@ commit = sys.argv[7]
fn_re = re.compile(r'^\s*(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+\w+')
env_re = re.compile(r'LESAVKA_[A-Z0-9_]+')
lazy_name_tokens = {'part', 'piece', 'chunk', 'misc', 'stuff', 'helpers2', 'new', 'old', 'tmp'}
expected_workspace_members = {'common', 'client', 'server', 'testing'}
expected_workspace_members = {'common', 'client', 'server'}
required_binary_paths = {
'lesavka-client': 'client/Cargo.toml',
'lesavka-server': 'server/Cargo.toml',
@ -190,7 +190,7 @@ def env_doc_violations(files: list[str]) -> list[str]:
docs_text = docs_path.read_text(encoding='utf-8')
found: set[str] = set()
scan_prefixes = ('client/', 'common/', 'server/', 'testing/', 'scripts/')
scan_prefixes = ('client/', 'common/', 'server/', 'scripts/')
scan_files = [
path for path in files
if path == 'Jenkinsfile' or path.endswith('.toml') or path.startswith(scan_prefixes)
@ -302,24 +302,43 @@ def integration_layout_violations() -> list[str]:
violations: list[str] = []
for file in sorted(root.rglob('*.rs')):
rel = repo_relative(str(file))
if rel is None or rel.startswith('target/') or rel.startswith('testing/'):
if rel is None or rel.startswith('target/'):
continue
parts = pathlib.Path(rel).parts
if len(parts) >= 2 and parts[1] == 'tests':
violations.append(
f'{rel}: integration tests must live under testing/tests/ instead of package-local tests/'
f'{rel}: integration tests must live under top-level tests/ instead of package-local tests/'
)
return violations
def testing_contract_violations() -> list[str]:
violations: list[str] = []
contract_dir = root / 'testing' / 'tests'
if not contract_dir.exists():
return ['testing/tests: missing dedicated top-level integration test directory']
taxonomy_dir = root / 'tests'
required_categories = {
'unit', 'component', 'integration', 'api', 'contract', 'e2e', 'system',
'performance', 'reliability', 'chaos', 'security', 'ui', 'installer',
'compatibility', 'regression', 'smoke', 'fixtures', 'helpers', 'golden',
'manual',
}
if not taxonomy_dir.exists():
return ['tests: missing repository-level test taxonomy directory']
test_files = sorted(contract_dir.rglob('*.rs'))
present_categories = {
path.name
for path in taxonomy_dir.iterdir()
if path.is_dir()
}
for category in sorted(required_categories - present_categories):
violations.append(f'tests/{category}: missing standard test category directory')
test_files = [
file
for file in sorted(taxonomy_dir.rglob('*.rs'))
if file != taxonomy_dir / 'lib.rs'
and 'helpers' not in file.relative_to(taxonomy_dir).parts
]
if not test_files:
return ['testing/tests: no integration test files found']
violations.append('tests: no categorized test bodies found')
filename_re = re.compile(r'^[a-z0-9_]+\.rs$')
required_markers = ('Scope:', 'Targets:', 'Why:')
@ -340,6 +359,9 @@ def testing_contract_violations() -> list[str]:
violations.append(f'{rel}: missing required module contract marker {marker}')
if '#[test]' not in text and '#[tokio::test]' not in text:
violations.append(f'{rel}: missing test entrypoints')
if 'misc' in pathlib.Path(rel).parts:
violations.append(f'{rel}: tests must use descriptive subsystem paths, not misc')
return violations
current = {}
@ -411,7 +433,7 @@ lines.append(f"files over 500 LOC: {totals['over_500']}")
lines.append(f"clippy warnings tracked: {totals['clippy_warnings']}")
lines.append(f"non-trivial undocumented functions tracked: {totals['doc_debt']}")
lines.append(f'legacy integration-test layout violations: {len(layout_violations)}')
lines.append(f'testing module contract violations: {len(testing_violations)}')
lines.append(f'test module contract violations: {len(testing_violations)}')
lines.append(f'repository policy violations: {len(repo_violations)}')
lines.append(f'naming policy violations: {len(naming_violations)}')
lines.append(f'script policy violations: {len(script_violations)}')
@ -451,7 +473,7 @@ if layout_violations:
if testing_violations:
lines.append('')
lines.append('testing module contract violations')
lines.append('test module contract violations')
lines.append('-' * 78)
lines.extend(testing_violations)

View File

@ -227,8 +227,8 @@
},
"client/src/input/mouse_event_contract_tests.rs": {
"clippy_warnings": 0,
"doc_debt": 14,
"loc": 439
"doc_debt": 13,
"loc": 457
},
"client/src/launcher/calibration.rs": {
"clippy_warnings": 0,
@ -1223,7 +1223,7 @@
"server/src/video_sinks/hevc_mjpeg_guard.rs": {
"clippy_warnings": 0,
"doc_debt": 2,
"loc": 120
"loc": 264
},
"server/src/video_sinks/mjpeg_spool.rs": {
"clippy_warnings": 0,
@ -1233,17 +1233,12 @@
"server/src/video_sinks/webcam_sink.rs": {
"clippy_warnings": 0,
"doc_debt": 6,
"loc": 494
"loc": 495
},
"server/src/video_support.rs": {
"clippy_warnings": 0,
"doc_debt": 1,
"loc": 340
},
"testing/src/lib.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 10
}
}
}

View File

@ -5,21 +5,37 @@ set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$ROOT"
if [[ "${LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS:-0}" != "1" ]]; then
cat <<'MSG'
Skipping disruptive input transport tests.
These tests create virtual keyboards/mice and can emit events into the active
desktop. Run them only on an isolated worker/session with:
LESAVKA_ALLOW_DISRUPTIVE_INPUT_TESTS=1 scripts/ci/input_transport_gate.sh
MSG
exit 0
fi
INPUT_TESTS=(
--test client_app_include_contract
--test client_inputs_contract
--test client_inputs_extra_contract
--test client_inputs_routing_contract
--test client_keyboard_activation_contract
--test client_keyboard_paste_rpc_contract
--test client_keyboard_process_contract
--test client_keyboard_shift_contract
--test client_keyboard_include_contract
--test client_keyboard_include_extra_contract
--test client_mouse_include_contract
--test client_mouse_include_extra_contract
--test client_mouse_uinput_contract
--test server_gadget_include_contract
--test server_main_binary_extra_contract
--test server_runtime_smoke_contract
)
cargo fmt --all -- --check
cargo check -q --bin lesavka-client --bin lesavka-server
cargo test -q -p lesavka_testing "${INPUT_TESTS[@]}"
cargo check -q -p lesavka_client --bin lesavka-client
cargo check -q -p lesavka_server --bin lesavka-server
cargo test -q -p lesavka_tests --features disruptive-input-tests "${INPUT_TESTS[@]}"

View File

@ -58,7 +58,7 @@ set +e
camera_status=${PIPESTATUS[0]}
echo
echo '==> media reliability contract tests'
cargo test -p lesavka_testing --color never "${MEDIA_TESTS[@]}"
cargo test -p lesavka_tests --color never "${MEDIA_TESTS[@]}"
contract_status=${PIPESTATUS[0]}
if [[ "${camera_status}" -ne 0 || "${contract_status}" -ne 0 ]]; then
exit 1
@ -183,7 +183,7 @@ summary = {
'status': 'ok' if final_status == 0 else 'failed',
'deterministic_status': 'ok' if status == 0 else 'failed',
'duration_seconds': duration_seconds,
'deterministic_tests': 'cargo test -p lesavka_testing media reliability contract subset',
'deterministic_tests': 'cargo test -p lesavka_tests media reliability contract subset',
'tracked_media_signals': tracked_signals,
'manual_checks': manual_checks,
'sync_probe': {

View File

@ -32,7 +32,7 @@ PERFORMANCE_TESTS=(
start_seconds=$(date +%s)
status=0
set +e
cargo test -p lesavka_testing "${PERFORMANCE_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}"
cargo test -p lesavka_tests "${PERFORMANCE_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}"
status=${PIPESTATUS[0]}
set -e
duration_seconds=$(($(date +%s) - start_seconds))

View File

@ -7,7 +7,7 @@ COVERAGE_LCOV="${REPORT_DIR}/coverage.lcov"
SUMMARY_TXT="${REPORT_DIR}/summary.txt"
METRICS_FILE="${REPORT_DIR}/metrics.prom"
BASELINE_JSON="${ROOT_DIR}/scripts/ci/quality_gate_baseline.json"
COVERAGE_CONTRACT_JSON="${ROOT_DIR}/testing/coverage_contract.json"
COVERAGE_CONTRACT_JSON="${ROOT_DIR}/tests/coverage_contract.json"
PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-}
mkdir -p "${REPORT_DIR}"
@ -294,7 +294,7 @@ metrics.append(f'platform_quality_gate_source_lines_over_500_total{{{labels}}} {
metrics.append('# HELP platform_quality_gate_repo_source_lines_over_500_total Count of repo source files over 500 LOC, including untracked working-tree files.')
metrics.append('# TYPE platform_quality_gate_repo_source_lines_over_500_total gauge')
metrics.append(f'platform_quality_gate_repo_source_lines_over_500_total{{{labels}}} {len(source_loc_over_500)}')
metrics.append('# HELP platform_quality_gate_contract_files_total Count of files covered by the strict testing coverage contract.')
metrics.append('# HELP platform_quality_gate_contract_files_total Count of files covered by the strict test coverage contract.')
metrics.append('# TYPE platform_quality_gate_contract_files_total gauge')
metrics.append(f'platform_quality_gate_contract_files_total{{{labels}}} {len(contract_files)}')
metrics.append('# HELP platform_quality_gate_contract_files_at_target_total Count of strict contract files meeting the line coverage target.')
@ -350,7 +350,7 @@ for item in files:
if contract_files:
lines.append('')
lines.append('strict testing coverage contract')
lines.append('strict test coverage contract')
lines.append('-' * 86)
for path in contract_files:
current = current_by_path.get(path)

View File

@ -31,8 +31,8 @@ if [[ -n "${SONARQUBE_HOST_URL:-}" && -n "${SONARQUBE_TOKEN:-}" ]] && command -v
if ! sonar-scanner \
-Dsonar.projectKey=lesavka \
-Dsonar.projectName=lesavka \
-Dsonar.sources=client/src,server/src,common/src,testing/src \
-Dsonar.tests=testing/tests \
-Dsonar.sources=client/src,server/src,common/src \
-Dsonar.tests=tests \
-Dsonar.host.url="${SONARQUBE_HOST_URL}" \
-Dsonar.token="${SONARQUBE_TOKEN}" \
>"${REPORT_DIR}/sonar-scanner.log" 2>&1; then

View File

@ -8,6 +8,10 @@ cd "$ROOT"
VIDEO_TESTS=(
--test client_launcher_layout_contract
--test video_downstream_feed_contract
--test downstream_blackout_recovery_contract
--test downstream_video_latency_budget_contract
--test downstream_video_mode_decoder_matrix_contract
--test downstream_video_stall_chaos_contract
--test server_video_include_contract
--test video_support_contract
--test client_output_video_include_contract
@ -15,11 +19,12 @@ VIDEO_TESTS=(
--test server_video_sink_smoke_contract
)
VIDEO_IGNORE_REGEX='(/common/src/(hid|paste|process_metrics)\.rs|/server/src/(audio|camera|camera_runtime|capture_power|gadget|paste|runtime_support|uvc_runtime)\.rs)'
VIDEO_IGNORE_REGEX='(^|/)(common/src/(hid|paste|process_metrics)\.rs|server/src/(audio|camera|calibration|capture_power|gadget|output_delay_probe|runtime_support|upstream_media_runtime)(/.*)?|server/src/(blind_healer|camera_runtime|handshake|paste|security|uvc_runtime)\.rs)'
cargo fmt --all -- --check
cargo check -q --bin lesavka-client --bin lesavka-server
cargo test -q -p lesavka_testing "${VIDEO_TESTS[@]}"
cargo check -q -p lesavka_client --bin lesavka-client
cargo check -q -p lesavka_server --bin lesavka-server
cargo test -q -p lesavka_tests "${VIDEO_TESTS[@]}"
cargo llvm-cov clean --workspace
cargo llvm-cov --workspace "${VIDEO_TESTS[@]}" \

View File

@ -0,0 +1,211 @@
#!/usr/bin/env bash
# Soft recovery ladder for a deployed Lesavka server.
set -euo pipefail
ACTION=${1:-recover}
LOG_PATH=${LESAVKA_RECOVERY_LOG:-/var/log/lesavka/recovery-ladder.log}
LAST_GOOD_DIR=${LESAVKA_RECOVERY_LAST_GOOD_DIR:-/var/lib/lesavka/recovery/last-good}
CHECK_TIMEOUT_SECONDS=${LESAVKA_RECOVERY_TIMEOUT_SECONDS:-60}
ALLOW_CORE_RESTART=${LESAVKA_RECOVERY_ALLOW_CORE_RESTART:-0}
ALLOW_REBOOT=${LESAVKA_RECOVERY_ALLOW_REBOOT:-0}
SERVER_BIND_ADDR=${LESAVKA_SERVER_BIND_ADDR:-0.0.0.0:50051}
LOCK_PATH=${LESAVKA_RECOVERY_LOCK:-/run/lesavka-recovery-ladder.lock}
LIVE_FILES=(
/usr/local/bin/lesavka-server
/usr/local/bin/lesavka-uvc
/usr/local/bin/lesavka-core.sh
/usr/local/bin/lesavka-uvc.sh
)
mkdir -p "$(dirname "$LOG_PATH")" "$LAST_GOOD_DIR" "$(dirname "$LOCK_PATH")"
log() {
printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$LOG_PATH" >&2
}
locked_main() {
exec 9>"$LOCK_PATH"
if ! flock -n 9; then
log "another recovery ladder run is already active; skipping"
exit 0
fi
main "$@"
}
nonempty_executable() {
[[ -s $1 && -x $1 ]]
}
server_port() {
printf '%s\n' "${SERVER_BIND_ADDR##*:}"
}
listener_ready() {
local port
port=$(server_port)
if ! command -v ss >/dev/null 2>&1; then
return 0
fi
ss -ltn "sport = :$port" 2>/dev/null | grep -q ":$port"
}
entrypoints_ready() {
local file
for file in "${LIVE_FILES[@]}"; do
if ! nonempty_executable "$file"; then
log "unhealthy: $file is missing, empty, or not executable"
return 1
fi
done
}
services_ready() {
systemctl is-active --quiet lesavka-core.service \
&& systemctl is-active --quiet lesavka-uvc.service \
&& systemctl is-active --quiet lesavka-server.service
}
health_ready() {
entrypoints_ready && services_ready && listener_ready
}
snapshot_last_good() {
local file dest
entrypoints_ready
install -d -m 0755 "$LAST_GOOD_DIR/usr-local-bin"
for file in "${LIVE_FILES[@]}"; do
dest="$LAST_GOOD_DIR/usr-local-bin/$(basename "$file")"
install -m 0755 "$file" "$dest"
done
log "snapshot: refreshed last-known-good Lesavka entrypoints"
}
restore_last_good() {
local file src
for file in "${LIVE_FILES[@]}"; do
src="$LAST_GOOD_DIR/usr-local-bin/$(basename "$file")"
if ! nonempty_executable "$src"; then
log "restore: missing last-known-good copy for $(basename "$file")"
return 1
fi
done
for file in "${LIVE_FILES[@]}"; do
src="$LAST_GOOD_DIR/usr-local-bin/$(basename "$file")"
install -m 0755 "$src" "$file"
done
log "restore: restored last-known-good Lesavka entrypoints"
}
wait_for_health() {
local deadline
deadline=$((SECONDS + CHECK_TIMEOUT_SECONDS))
while (( SECONDS <= deadline )); do
if health_ready; then
return 0
fi
sleep 5
done
return 1
}
restart_server_only() {
log "step 1: restarting lesavka-server only"
systemctl reset-failed lesavka-server.service >/dev/null 2>&1 || true
systemctl restart lesavka-server.service
}
restart_uvc_and_server() {
log "step 2: restarting UVC helper and server"
systemctl reset-failed lesavka-uvc.service lesavka-server.service >/dev/null 2>&1 || true
systemctl restart lesavka-uvc.service
systemctl restart lesavka-server.service
}
restart_full_stack_if_allowed() {
if [[ $ALLOW_CORE_RESTART == 0 || $ALLOW_CORE_RESTART == false || $ALLOW_CORE_RESTART == no ]]; then
log "step 4: core restart disabled; preserving attached USB gadget"
return 1
fi
log "step 4: restarting full stack because LESAVKA_RECOVERY_ALLOW_CORE_RESTART is enabled"
systemctl reset-failed lesavka-core.service lesavka-uvc.service lesavka-server.service >/dev/null 2>&1 || true
systemctl restart lesavka-core.service
systemctl restart lesavka-uvc.service
systemctl restart lesavka-server.service
}
reboot_if_allowed() {
if [[ $ALLOW_REBOOT == 0 || $ALLOW_REBOOT == false || $ALLOW_REBOOT == no ]]; then
log "step 5: reboot disabled; leaving host online for operator inspection"
return 1
fi
log "step 5: rebooting because LESAVKA_RECOVERY_ALLOW_REBOOT is enabled"
systemctl reboot
}
recover() {
if health_ready; then
snapshot_last_good
log "healthy: no recovery needed"
return 0
fi
log "unhealthy: waiting up to ${CHECK_TIMEOUT_SECONDS}s before recovery"
if wait_for_health; then
snapshot_last_good
log "healthy after wait: no recovery needed"
return 0
fi
restart_server_only || true
if wait_for_health; then
snapshot_last_good
return 0
fi
restart_uvc_and_server || true
if wait_for_health; then
snapshot_last_good
return 0
fi
log "step 3: restoring last-known-good entrypoints"
if restore_last_good; then
restart_uvc_and_server || true
if wait_for_health; then
snapshot_last_good
return 0
fi
fi
restart_full_stack_if_allowed || true
if wait_for_health; then
snapshot_last_good
return 0
fi
reboot_if_allowed || return 1
}
main() {
case "$ACTION" in
check)
health_ready
;;
snapshot)
snapshot_last_good
;;
restore)
restore_last_good
;;
recover)
recover
;;
*)
echo "usage: $0 {check|snapshot|restore|recover}" >&2
exit 2
;;
esac
}
locked_main "$@"

View File

@ -131,6 +131,102 @@ require_gst_element() {
exit 1
}
gst_element_available() {
gst-inspect-1.0 "$1" >/dev/null 2>&1
}
first_available_gst_element() {
local element
for element in "$@"; do
if gst_element_available "$element"; then
printf '%s\n' "$element"
return 0
fi
done
return 1
}
report_client_media_acceleration() {
log "1e. Inspecting client media acceleration routes"
local hevc_encoder=""
local h264_decoder=""
local opus_encoder=""
local opus_decoder=""
local webrtc_dsp=""
local proprietary_bits=()
local opensource_bits=()
hevc_encoder=$(first_available_gst_element \
nvh265enc \
vah265enc \
vaapih265enc \
v4l2h265enc \
x265enc || true)
h264_decoder=$(first_available_gst_element \
nvh264dec \
nvh264sldec \
vah264dec \
vaapih264dec \
v4l2h264dec \
v4l2slh264dec \
avdec_h264 \
openh264dec || true)
opus_encoder=$(first_available_gst_element opusenc || true)
opus_decoder=$(first_available_gst_element opusdec || true)
webrtc_dsp=$(first_available_gst_element webrtcdsp || true)
for element in nvh265enc nvh264dec nvh264sldec; do
if gst_element_available "$element"; then
proprietary_bits+=("$element")
fi
done
for element in vah265enc vaapih265enc v4l2h265enc vah264dec vaapih264dec v4l2h264dec v4l2slh264dec; do
if gst_element_available "$element"; then
opensource_bits+=("$element")
fi
done
if command -v nvidia-smi >/dev/null 2>&1; then
echo " ↪ nvidia-smi is available; proprietary NVIDIA driver tooling is present"
else
echo " ↪ nvidia-smi is not available; NVIDIA proprietary tooling was not detected"
fi
if [[ ${#proprietary_bits[@]} -gt 0 ]]; then
echo " ↪ proprietary NVIDIA GStreamer route: ${proprietary_bits[*]}"
else
echo " ↪ proprietary NVIDIA GStreamer route: not exposed"
fi
if [[ ${#opensource_bits[@]} -gt 0 ]]; then
echo " ↪ open-source VAAPI/V4L2 GStreamer route: ${opensource_bits[*]}"
else
echo " ↪ open-source VAAPI/V4L2 GStreamer route: not exposed"
fi
if [[ -n $hevc_encoder ]]; then
echo " ↪ upstream HEVC encoder candidate: $hevc_encoder"
else
echo "⚠️ no HEVC encoder was detected; upstream HEVC will need NVIDIA/VAAPI/V4L2 or x265enc"
fi
if [[ -n $h264_decoder ]]; then
echo " ↪ downstream H.264 decoder candidate: $h264_decoder"
else
echo "⚠️ no H.264 decoder was detected; downstream eye preview may fall back to decodebin"
fi
if [[ -n $opus_encoder && -n $opus_decoder ]]; then
echo "✅ Opus upstream audio transport route: encoder=$opus_encoder decoder=$opus_decoder"
else
echo "⚠️ Opus upstream audio route is not fully exposed; Lesavka will fall back to PCM"
fi
if [[ -n $webrtc_dsp ]]; then
echo "✅ microphone noise suppression route: $webrtc_dsp"
else
echo " ↪ microphone noise suppression route: unavailable; raw microphone path still works"
fi
echo " ↪ override decoder route with LESAVKA_H264_DECODER=<element> or LESAVKA_H264_DECODER_PREFERENCE=software"
}
require_kernel_module() {
local module=$1
local why=$2
@ -161,6 +257,67 @@ run_as_user() {
sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@"
}
install_verified_executable() {
local src=$1
local dest=$2
local label=${3:-$dest}
local dest_dir dest_base tmp
if [[ ! -s "$src" ]]; then
echo "❌ refusing to install $label: source '$src' is missing or empty." >&2
echo " Preserving the existing installed executable at '$dest'." >&2
exit 1
fi
if [[ ! -x "$src" ]]; then
echo "❌ refusing to install $label: source '$src' is not executable." >&2
echo " Preserving the existing installed executable at '$dest'." >&2
exit 1
fi
dest_dir=$(dirname "$dest")
dest_base=$(basename "$dest")
sudo install -d -m 0755 "$dest_dir"
tmp=$(sudo mktemp "$dest_dir/.${dest_base}.install.XXXXXX")
sudo rm -f "$tmp"
sudo install -Dm755 "$src" "$tmp"
if ! sudo test -s "$tmp" || ! sudo test -x "$tmp"; then
sudo rm -f "$tmp"
echo "❌ refusing to replace $label: staged install output was not a non-empty executable." >&2
echo " Preserving the existing installed executable at '$dest'." >&2
exit 1
fi
sudo mv -f "$tmp" "$dest"
sudo chmod 0755 "$dest"
}
pacman_install() {
local log_file
log_file=$(mktemp --tmpdir="$TMPDIR" lesavka-pacman.XXXXXX.log)
if sudo pacman -Sq --needed --noconfirm "$@" 2>&1 | tee "$log_file"; then
rm -f "$log_file"
return 0
fi
if grep -Eq "breaks dependency '.*pipewire" "$log_file"; then
cat >&2 <<'MSG'
❌ Arch stopped the package transaction because PipeWire packages are at mixed exact versions.
Lesavka now installs PipeWire as one coherent set, but this host still needs a sync transaction.
Run:
sudo pacman -Syu
Then rerun the Lesavka client installer.
MSG
elif grep -Eq "failed retrieving file|failed to retrieve some files|failed to commit transaction \\(failed to retrieve some files\\)" "$log_file"; then
cat >&2 <<'MSG'
❌ Arch failed while downloading packages from the configured mirrors.
No Lesavka files were replaced. Refresh or choose healthier mirrors, then rerun the installer.
Good first retry:
sudo pacman -Syu --disable-download-timeout
If mirrors keep timing out, refresh /etc/pacman.d/mirrorlist before retrying.
MSG
fi
echo " pacman log: $log_file" >&2
exit 1
}
fetch_client_pki_bundle() {
[[ $CLIENT_PKI_AUTO_FETCH != 0 && $CLIENT_PKI_AUTO_FETCH != false && $CLIENT_PKI_AUTO_FETCH != no ]] || return 1
[[ $CLIENT_PKI_SSH_SOURCE == *:* ]] || return 1
@ -229,9 +386,10 @@ fi
REPO_URL=${REPO_URL:-$DEFAULT_REPO_URL}
log "1. Installing base packages"
sudo pacman -Sq --needed --noconfirm \
pacman_install \
git rustup protobuf abseil-cpp gcc clang llvm-libs compiler-rt evtest base-devel libpulse \
pipewire pipewire-pulse wireplumber alsa-utils gst-plugin-pipewire \
libpipewire pipewire pipewire-audio pipewire-alsa pipewire-jack pipewire-pulse wireplumber \
alsa-utils gst-plugin-pipewire \
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils openssl
@ -248,7 +406,7 @@ ensure_yay() {
log "1b. Installing grpcurl"
if sudo pacman -Si grpcurl >/dev/null 2>&1; then
sudo pacman -Sq --needed --noconfirm grpcurl
pacman_install grpcurl
else
ensure_yay
if ! run_as_user yay -S --needed --noconfirm grpcurl-bin; then
@ -284,6 +442,7 @@ require_kernel_module snd_usb_audio "USB microphones and USB headsets"
require_gst_element pulsesrc
require_gst_element pulsesink
require_gst_element pipewiresrc
report_client_media_acceleration
protoc --version >/dev/null
if ! run_as_user pactl info >/dev/null 2>&1; then
echo "⚠️ pactl is installed, but no PulseAudio/PipeWire Pulse server is reachable right now."
@ -305,7 +464,7 @@ run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC/client' && cargo clean && car
# 5. install binary
log "5. Installing launchable client binaries"
sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client
install_verified_executable "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client "lesavka-client"
sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka
sudo install -d -m 0755 -o "$ORIG_USER" -g "$ORIG_USER" "$USER_HOME/.local/bin"
sudo ln -sf /usr/local/bin/lesavka-client "$USER_HOME/.local/bin/lesavka-client"

View File

@ -13,6 +13,7 @@ INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto}
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}
INSTALL_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}
INSTALL_UPLINK_AUDIO_CODEC=${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}
INSTALL_UVC_FRAME_META=${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}}
INSTALL_UVC_FRAME_META_LOG_PATH=${LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH:-${LESAVKA_UVC_FRAME_META_LOG_PATH:-/tmp/lesavka-uvc-frame-meta.jsonl}}
INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}
@ -26,6 +27,14 @@ DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0
DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=110000
DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0
DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952
DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US
DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US
DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US
DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US
DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US
DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US
DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US
DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US
LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000
PREVIOUS_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000
PREVIOUS_TUNED_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=1260000
@ -149,6 +158,12 @@ ensure_hevc_decode_support() {
else
echo "⚠️ no HEVC decoder exposed to GStreamer; install gst-libav or a v4l2 HEVC decoder before enabling HEVC transport"
fi
if gst-inspect-1.0 opusdec >/dev/null 2>&1; then
echo "✅ Opus upstream audio decoder exposed: opusdec"
else
echo "⚠️ no Opus decoder exposed to GStreamer; upstream Opus audio will be dropped until gst-plugins-base is available"
fi
}
manifest_package_version() {
@ -911,6 +926,67 @@ run_as_user() {
sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@"
}
pacman_install() {
local log_file
log_file=$(mktemp --tmpdir="$TMPDIR" lesavka-pacman.XXXXXX.log)
if sudo pacman -Sq --needed --noconfirm "$@" 2>&1 | tee "$log_file"; then
rm -f "$log_file"
return 0
fi
if grep -Eq "breaks dependency '.*pipewire" "$log_file"; then
cat >&2 <<'MSG'
❌ Arch stopped the package transaction because PipeWire packages are at mixed exact versions.
Lesavka installs PipeWire as one coherent set, but this host still needs a sync transaction.
Run during a safe maintenance window:
sudo pacman -Syu
Then rerun the Lesavka server installer.
MSG
elif grep -Eq "failed retrieving file|failed to retrieve some files|failed to commit transaction \\(failed to retrieve some files\\)" "$log_file"; then
cat >&2 <<'MSG'
❌ Arch failed while downloading packages from the configured mirrors.
No Lesavka files were replaced. Refresh or choose healthier mirrors, then rerun the installer.
Good first retry:
sudo pacman -Syu --disable-download-timeout
If mirrors keep timing out, refresh /etc/pacman.d/mirrorlist before retrying.
MSG
fi
echo " pacman log: $log_file" >&2
exit 1
}
install_verified_executable() {
local src=$1
local dest=$2
local label=${3:-$dest}
local dest_dir dest_base tmp
if [[ ! -s "$src" ]]; then
echo "❌ refusing to install $label: source '$src' is missing or empty." >&2
echo " Preserving the existing installed executable at '$dest'." >&2
exit 1
fi
if [[ ! -x "$src" ]]; then
echo "❌ refusing to install $label: source '$src' is not executable." >&2
echo " Preserving the existing installed executable at '$dest'." >&2
exit 1
fi
dest_dir=$(dirname "$dest")
dest_base=$(basename "$dest")
sudo install -d -m 0755 "$dest_dir"
tmp=$(sudo mktemp "$dest_dir/.${dest_base}.install.XXXXXX")
sudo rm -f "$tmp"
sudo install -Dm755 "$src" "$tmp"
if ! sudo test -s "$tmp" || ! sudo test -x "$tmp"; then
sudo rm -f "$tmp"
echo "❌ refusing to replace $label: staged install output was not a non-empty executable." >&2
echo " Preserving the existing installed executable at '$dest'." >&2
exit 1
fi
sudo mv -f "$tmp" "$dest"
sudo chmod 0755 "$dest"
}
CAPTURE_DISCOVERY_RELAY_PRESENT=0
CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE=0
CAPTURE_DISCOVERY_POWER_BORROWED=0
@ -966,28 +1042,32 @@ fi
REPO_URL=${REPO_URL:-$DEFAULT_REPO_URL}
echo "==> 1a. Base packages"
sudo pacman -Sq --needed --noconfirm git \
rustup \
protobuf \
abseil-cpp \
gcc \
alsa-utils \
pipewire \
pipewire-pulse \
tailscale \
base-devel \
v4l-utils \
gstreamer \
gst-plugins-base \
gst-plugins-base-libs \
gst-plugins-good \
gst-plugins-bad \
gst-plugins-bad-libs \
gst-plugins-ugly \
gst-libav \
tcpdump \
lsof \
openssl
pacman_install git \
rustup \
protobuf \
abseil-cpp \
gcc \
alsa-utils \
libpipewire \
pipewire \
pipewire-audio \
pipewire-alsa \
pipewire-jack \
pipewire-pulse \
tailscale \
base-devel \
v4l-utils \
gstreamer \
gst-plugins-base \
gst-plugins-base-libs \
gst-plugins-good \
gst-plugins-bad \
gst-plugins-bad-libs \
gst-plugins-ugly \
gst-libav \
tcpdump \
lsof \
openssl
if ! command -v yay >/dev/null 2>&1; then
echo "==> 1b. installing yay from AUR ..."
run_as_user env TMPDIR="$TMPDIR" bash -c '
@ -1095,11 +1175,12 @@ echo "==> 4c. Source build"
run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC_DIR/server' && cargo clean && cargo build --release --bins"
echo "==> 5. Install binaries"
sudo install -Dm755 "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server
sudo install -Dm755 "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh
sudo install -Dm755 "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity
install_verified_executable "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server "lesavka-server"
install_verified_executable "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc "lesavka-uvc"
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh"
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh"
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder"
install_verified_executable "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity "lesavka-uac-sanity"
echo "==> 5b. Runtime environment defaults"
sudo install -d -m 0755 /etc/lesavka
@ -1119,6 +1200,8 @@ fi
fi
printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}"
printf 'LESAVKA_CAM_CODEC=%s\n' "${INSTALL_CAM_CODEC}"
printf 'LESAVKA_UPLINK_CAMERA_CODEC=%s\n' "${INSTALL_CAM_CODEC}"
printf 'LESAVKA_UPLINK_AUDIO_CODEC=%s\n' "${INSTALL_UPLINK_AUDIO_CODEC}"
printf 'LESAVKA_CAM_WIDTH=%s\n' "${LESAVKA_CAM_WIDTH:-1920}"
printf 'LESAVKA_CAM_HEIGHT=%s\n' "${LESAVKA_CAM_HEIGHT:-1080}"
printf 'LESAVKA_CAM_FPS=%s\n' "${LESAVKA_CAM_FPS:-30}"
@ -1143,6 +1226,14 @@ fi
printf 'LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}"
printf 'LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}"
printf 'LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_VIDEO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US}"
printf 'LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}"
printf 'LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}"
printf 'LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}"
printf 'LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_PCM_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}"
printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}"
printf 'LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_MJPEG_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}"
printf 'LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}"
printf 'LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US:-$DEFAULT_HEVC_OPUS_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US}"
printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US}"
printf 'LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US=%s\n' "${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US:-$DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US}"
printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "$(resolve_upstream_audio_playout_offset_us)"
@ -1154,6 +1245,8 @@ fi
printf 'LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP=%s\n' "${LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP:-1}"
printf 'LESAVKA_UVC_HEVC_SIZE_DROP_PCT=%s\n' "${LESAVKA_UVC_HEVC_SIZE_DROP_PCT:-45}"
printf 'LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES=%s\n' "${LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES:-65536}"
printf 'LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES=%s\n' "${LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES:-12}"
printf 'LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT=%s\n' "${LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT:-92}"
printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}"
printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}"
printf 'LESAVKA_UVC_WIDTH=%s\n' "${LESAVKA_UVC_WIDTH:-1280}"
@ -1327,7 +1420,38 @@ sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \
/usr/local/bin/lesavka-watchdog.sh \
/etc/lesavka/watchdog.touch
echo "==> 6e. Systemd units - recovery ladder"
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-recovery-ladder.service >/dev/null
[Unit]
Description=lesavka soft recovery ladder
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/lesavka-recovery-ladder recover
Environment=LESAVKA_RECOVERY_TIMEOUT_SECONDS=60
Environment=LESAVKA_RECOVERY_ALLOW_CORE_RESTART=0
Environment=LESAVKA_RECOVERY_ALLOW_REBOOT=0
EnvironmentFile=-/etc/lesavka/server.env
EnvironmentFile=-/etc/lesavka/uvc.env
UNIT
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-recovery-ladder.timer >/dev/null
[Unit]
Description=periodically check and softly recover lesavka services
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
AccuracySec=10s
Persistent=true
[Install]
WantedBy=timers.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable lesavka-recovery-ladder.timer
if [[ "$UVC_ENV_CHANGED" == "1" ]] && systemctl is-active --quiet lesavka-uvc; then
sudo systemctl restart lesavka-uvc
@ -1348,6 +1472,8 @@ clear_stale_server_listener
sudo systemctl reset-failed lesavka-server >/dev/null 2>&1 || true
sudo systemctl restart lesavka-server
validate_server_ready
sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true
sudo systemctl start lesavka-recovery-ladder.timer
INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true)
INSTALLED_SHA=$(git -C "$SCRIPT_REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true)
PERSISTED_CAM_OUTPUT=$(grep '^LESAVKA_CAM_OUTPUT=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)

View File

@ -1,13 +1,7 @@
#!/usr/bin/env bash
# scripts/manual/run_client_to_rct_transport_probe.sh
# Manual: client-origin bundled transport probe to the RCT UVC/UAC endpoints.
# Not part of CI; hardware/lab manual only.
#
# This runner keeps server->RCT calibration tooling untouched. It starts an
# RCT capture, injects deterministic flash/tone media through
# `lesavka-sync-probe`, then measures final sync, freshness, and smoothness from
# the captured UVC/UAC output.
# Manual hardware probe: inject bundled synthetic client media, then measure
# final RCT UVC/UAC sync, freshness, and smoothness without mutating the server.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)"
@ -27,6 +21,7 @@ REMOTE_PULSE_AUDIO_ANCHOR_SILENCE=${REMOTE_PULSE_AUDIO_ANCHOR_SILENCE:-1}
REMOTE_CAPTURE_READY_TIMEOUT_SECONDS=${REMOTE_CAPTURE_READY_TIMEOUT_SECONDS:-30}
REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS:-1}
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-3}
REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS=${REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS:-1}
LESAVKA_CLIENT_RCT_START_DELAY_SECONDS=${LESAVKA_CLIENT_RCT_START_DELAY_SECONDS:-0}
PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-20}
@ -195,6 +190,7 @@ start_tethys_capture() {
"${REMOTE_PULSE_VIDEO_MODE}" \
"${REMOTE_PULSE_AUDIO_ANCHOR_SILENCE}" \
"${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS}" \
"${REMOTE_CAPTURE_PREROLL_SETTLE_SECONDS}" \
"${REMOTE_CAPTURE_READY_SETTLE_SECONDS}" \
"${CAPTURE_READY_MARKER}" \
>"${LOCAL_CAPTURE_LOG}" 2>&1 <<'REMOTE_CAPTURE_SCRIPT' &
@ -209,8 +205,9 @@ pulse_tool=$7
video_mode=$8
anchor_silence=$9
preroll_discard=${10}
ready_settle=${11}
ready_marker=${12}
preroll_settle=${11}
ready_settle=${12}
ready_marker=${13}
resolve_video_device() {
find /dev/v4l/by-id -maxdepth 1 -type l \
@ -270,10 +267,14 @@ run_preroll() {
local seconds=$2
[[ "${seconds}" =~ ^[0-9]+$ && "${seconds}" -gt 0 ]] || return 0
printf 'discarding %ss of post-enumeration capture before probe\n' "${seconds}" >&2
timeout --kill-after=2 --signal=INT "${seconds}" \
timeout --kill-after=5 --signal=INT "$((seconds + 5))" \
gst-launch-1.0 -q -e v4l2src device="${video_device}" do-timestamp=true num-buffers="$((fps * seconds))" \
! "image/jpeg,width=${width},height=${height},framerate=${fps}/1" ! fakesink \
>/dev/null 2>&1 || true
if [[ "${preroll_settle}" =~ ^[0-9]+$ && "${preroll_settle}" -gt 0 ]]; then
printf 'settling %ss after preroll discard\n' "${preroll_settle}" >&2
sleep "${preroll_settle}"
fi
}
run_gst_pulse_capture() {

View File

@ -33,7 +33,9 @@ SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
LESAVKA_SERVER_RC_CORE_WEBCAM_MODES=${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES:-1280x720@20,1280x720@30,1920x1080@20,1920x1080@30}
LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES}}
LESAVKA_SERVER_RC_PROFILE=${LESAVKA_SERVER_RC_PROFILE:-mjpeg}
LESAVKA_SERVER_RC_AUDIO_PROFILE=${LESAVKA_SERVER_RC_AUDIO_PROFILE:-pcm}
LESAVKA_SERVER_RC_NORMALIZED_PROFILE=mjpeg
LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE=pcm
LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US:-${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}}
LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-135090}
LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US:-1280x720@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1280x720@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}}
@ -52,6 +54,15 @@ case "${LESAVKA_SERVER_RC_PROFILE,,}" in
LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-${LESAVKA_SERVER_RC_MJPEG_MODE_DELAYS_US}}
;;
esac
case "${LESAVKA_SERVER_RC_AUDIO_PROFILE,,}" in
opus|compressed|voice)
LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE=opus
;;
*)
LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE=pcm
;;
esac
LESAVKA_SERVER_RC_CALIBRATION_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}+${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}"
LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES=${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES:-1280x720,1920x1080}
LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS=${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS:-20,30}
LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX:-Logitech|BRIO|C9[0-9]+|HD UVC WebCam|USB2[.]0 HD|Integrated Camera|Webcam|Camera}
@ -1007,13 +1018,19 @@ reconfigure_server_mode() {
LESAVKA_UVC_HEIGHT="${height}" \
LESAVKA_UVC_FPS="${fps}" \
LESAVKA_SERVER_RC_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \
LESAVKA_CALIBRATION_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \
LESAVKA_SERVER_RC_AUDIO_PROFILE="${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}" \
LESAVKA_CALIBRATION_PROFILE="${LESAVKA_SERVER_RC_CALIBRATION_PROFILE}" \
LESAVKA_UPLINK_CAMERA_CODEC="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \
LESAVKA_UPLINK_AUDIO_CODEC="${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}" \
LESAVKA_CAM_CODEC="${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \
LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US="${audio_delay_us}" \
LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US="${video_delay_us}" \
LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \
LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_DELAYS_US}" \
LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \
LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \
LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \
LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US="${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \
bash -c "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}"
return 0
fi
@ -1034,6 +1051,8 @@ reconfigure_server_mode() {
"${interval}" \
"${LESAVKA_SERVER_RC_RECONFIGURE_CODEC}" \
"${LESAVKA_SERVER_RC_NORMALIZED_PROFILE}" \
"${LESAVKA_SERVER_RC_NORMALIZED_AUDIO_PROFILE}" \
"${LESAVKA_SERVER_RC_CALIBRATION_PROFILE}" \
"${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" \
"${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD}" \
"${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS}" \
@ -1050,14 +1069,16 @@ fps=$4
interval=$5
codec=$6
ingress_profile=$7
allow_gadget_reset=$8
force_gadget_rebuild=$9
settle_seconds=${10}
verbose=${11}
audio_delay_us=${12}
video_delay_us=${13}
audio_delay_map=${14}
video_delay_map=${15}
audio_profile=$8
calibration_profile=$9
allow_gadget_reset=${10}
force_gadget_rebuild=${11}
settle_seconds=${12}
verbose=${13}
audio_delay_us=${14}
video_delay_us=${15}
audio_delay_map=${16}
video_delay_map=${17}
set_env_value() {
local file=$1
@ -1102,7 +1123,8 @@ touch /etc/lesavka/server.env /etc/lesavka/uvc.env
set_env_value /etc/lesavka/server.env LESAVKA_CAM_OUTPUT uvc
set_env_value /etc/lesavka/server.env LESAVKA_CAM_CODEC "${ingress_profile}"
set_env_value /etc/lesavka/server.env LESAVKA_UPLINK_CAMERA_CODEC "${ingress_profile}"
set_env_value /etc/lesavka/server.env LESAVKA_CALIBRATION_PROFILE "${ingress_profile}"
set_env_value /etc/lesavka/server.env LESAVKA_UPLINK_AUDIO_CODEC "${audio_profile}"
set_env_value /etc/lesavka/server.env LESAVKA_CALIBRATION_PROFILE "${calibration_profile}"
set_env_value /etc/lesavka/server.env LESAVKA_UVC_CODEC "${codec}"
set_env_value /etc/lesavka/server.env LESAVKA_UVC_WIDTH "${width}"
set_env_value /etc/lesavka/server.env LESAVKA_UVC_HEIGHT "${height}"
@ -1126,6 +1148,24 @@ case "${ingress_profile}" in
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_VIDEO_PLAYOUT_OFFSET_US "${video_delay_us}"
;;
esac
case "${ingress_profile}+${audio_profile}" in
hevc+opus)
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}"
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}"
;;
hevc+pcm)
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}"
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}"
;;
mjpeg+opus)
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}"
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}"
;;
*)
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}"
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}"
;;
esac
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_CODEC "${codec}"
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_WIDTH "${width}"
@ -2285,7 +2325,7 @@ fi
echo "==> server-to-RC mode matrix"
echo " ↪ modes=${LESAVKA_SERVER_RC_MODES}"
echo " ↪ mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}"
echo " ↪ profile=${LESAVKA_SERVER_RC_PROFILE} capture_seconds=${CAPTURE_SECONDS:-auto} probe_timeout_seconds=${PROBE_TIMEOUT_SECONDS:-auto}"
echo " ↪ profile=${LESAVKA_SERVER_RC_PROFILE} audio_profile=${LESAVKA_SERVER_RC_AUDIO_PROFILE} calibration_profile=${LESAVKA_SERVER_RC_CALIBRATION_PROFILE} capture_seconds=${CAPTURE_SECONDS:-auto} probe_timeout_seconds=${PROBE_TIMEOUT_SECONDS:-auto}"
echo " ↪ repeat_count=${LESAVKA_SERVER_RC_REPEAT_COUNT} verbose_probes=${LESAVKA_SERVER_RC_VERBOSE_PROBES}"
echo " ↪ video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
echo " ↪ audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}"

View File

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

View File

@ -2,6 +2,9 @@
#![forbid(unsafe_code)]
//! Server-side audio capture, watchdogs, and microphone gadget input handling.
include!("audio/ear_capture.rs");
mod opus_decode {
include!("audio/opus_decode.rs");
}
include!("audio/voice_input.rs");
#[cfg(test)]

View File

@ -0,0 +1,190 @@
// Server-side Opus decode for upstream microphone packets.
//
// Why: UAC output remains a raw PCM gadget sink; compressed transport packets
// must be decoded immediately at ingress so existing playout timing and
// calibration continue to operate on the same PCM contract.
use anyhow::{Context, Result, bail};
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use lesavka_common::{
audio_transport,
lesavka::AudioPacket,
};
use std::time::Duration;
const OPUS_DECODE_PULL_TIMEOUT: Duration = Duration::from_millis(30);
pub(super) struct OpusPacketDecoder {
_pipeline: gst::Pipeline,
appsrc: gst_app::AppSrc,
appsink: gst_app::AppSink,
}
impl OpusPacketDecoder {
pub(super) fn new() -> Result<Self> {
gst::init().context("gst init")?;
if gst::ElementFactory::find("opusdec").is_none() {
bail!("GStreamer opusdec plugin is not available");
}
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 ! \
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)?.downcast().expect("pipeline");
let appsrc = pipeline
.by_name("src")
.context("missing opus decoder appsrc")?
.downcast::<gst_app::AppSrc>()
.expect("opus decoder appsrc");
let appsink = pipeline
.by_name("sink")
.context("missing opus decoder appsink")?
.downcast::<gst_app::AppSink>()
.expect("opus decoder appsink");
pipeline
.set_state(gst::State::Playing)
.context("start opus decoder pipeline")?;
Ok(Self {
_pipeline: pipeline,
appsrc,
appsink,
})
}
pub(super) fn decode_packet(&mut self, packet: &AudioPacket) -> Result<Option<AudioPacket>> {
let mut buffer = gst::Buffer::from_slice(packet.data.clone());
if let Some(meta) = buffer.get_mut() {
let pts = gst::ClockTime::from_useconds(packet.pts);
meta.set_pts(Some(pts));
meta.set_dts(Some(pts));
meta.set_duration(Some(gst::ClockTime::from_useconds(u64::from(
packet.frame_duration_us.max(1),
))));
}
self.appsrc
.push_buffer(buffer)
.context("push opus into decoder")?;
let Some(sample) =
self.appsink
.try_pull_sample(gst::ClockTime::from_nseconds(
OPUS_DECODE_PULL_TIMEOUT
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
))
else {
return Ok(None);
};
let decoded = sample
.buffer()
.context("opus decoder sample missing buffer")?
.map_readable()
.context("map decoded pcm")?
.to_vec();
if decoded.is_empty() {
return Ok(None);
}
let mut output = AudioPacket {
data: decoded,
..packet.clone()
};
audio_transport::mark_packet_pcm_s16le(&mut output);
Ok(Some(output))
}
}
impl Drop for OpusPacketDecoder {
fn drop(&mut self) {
let _ = self._pipeline.set_state(gst::State::Null);
}
}
#[cfg(test)]
mod tests {
use super::*;
use lesavka_common::lesavka::AudioEncoding;
#[test]
fn opus_decoder_roundtrips_to_pcm_when_plugins_are_available() {
let _ = gst::init();
if gst::ElementFactory::find("opusenc").is_none()
|| gst::ElementFactory::find("opusdec").is_none()
{
return;
}
let Some(opus_payload) = encode_silent_opus_payload() else {
return;
};
let packet = AudioPacket {
pts: 42_000,
encoding: AudioEncoding::Opus as i32,
sample_rate: 48_000,
channels: 2,
frame_duration_us: 20_000,
data: opus_payload,
..AudioPacket::default()
};
let mut decoder = OpusPacketDecoder::new().expect("opus decoder");
let decoded = decoder
.decode_packet(&packet)
.expect("decode")
.expect("decoded pcm");
assert_eq!(decoded.pts, 42_000);
assert_eq!(decoded.encoding, AudioEncoding::PcmS16le as i32);
assert_eq!(decoded.sample_rate, 48_000);
assert_eq!(decoded.channels, 2);
assert!(
decoded.data.len() >= 1_000,
"decoded PCM should be far larger than one compressed Opus frame"
);
}
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 ! \
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()?;
for index in 0..4u64 {
let mut buffer = gst::Buffer::from_slice(vec![0; 3_840]);
if let Some(meta) = buffer.get_mut() {
let pts = gst::ClockTime::from_useconds(index * 20_000);
meta.set_pts(Some(pts));
meta.set_dts(Some(pts));
meta.set_duration(Some(gst::ClockTime::from_useconds(20_000)));
}
appsrc.push_buffer(buffer).ok()?;
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(50)) {
let payload = sample.buffer()?.map_readable().ok()?.to_vec();
let _ = pipeline.set_state(gst::State::Null);
if !payload.is_empty() {
return Some(payload);
}
}
}
let _ = pipeline.set_state(gst::State::Null);
None
}
}

View File

@ -54,6 +54,7 @@ pub struct Voice {
_pipe: gst::Pipeline, // keep pipeline alive
clock_aligned: bool,
tap: ClipTap,
opus_decoder: Option<opus_decode::OpusPacketDecoder>,
}
impl Drop for Voice {
@ -206,6 +207,7 @@ impl Voice {
_pipe: pipeline,
clock_aligned: false,
tap: ClipTap::new("voice", Duration::from_secs(60)),
opus_decoder: opus_decode::OpusPacketDecoder::new().ok(),
})
}
@ -362,12 +364,65 @@ impl Voice {
_pipe: pipeline,
clock_aligned: !clock_align_enabled,
tap: ClipTap::new("voice", Duration::from_secs(60)),
opus_decoder: opus_decode::OpusPacketDecoder::new().ok(),
})
}
/// Keeps `push` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
pub fn push(&mut self, pkt: &AudioPacket) {
let decoded;
let pkt = if lesavka_common::audio_transport::packet_is_raw_pcm_s16le(pkt) {
pkt
} else if lesavka_common::audio_transport::normalize_audio_encoding(pkt.encoding)
== lesavka_common::lesavka::AudioEncoding::Opus
{
let Some(decoder) = self.opus_decoder.as_mut() else {
tracing::warn!(
target: "lesavka_server::audio",
pts = pkt.pts,
bytes = pkt.data.len(),
encoding = pkt.encoding,
"🎤⚠️ dropping Opus microphone packet because opusdec is unavailable"
);
return;
};
match decoder.decode_packet(pkt) {
Ok(Some(packet)) => {
decoded = packet;
&decoded
}
Ok(None) => {
tracing::debug!(
target: "lesavka_server::audio",
pts = pkt.pts,
bytes = pkt.data.len(),
"🎤 Opus decoder produced no PCM yet"
);
return;
}
Err(err) => {
tracing::warn!(
target: "lesavka_server::audio",
%err,
pts = pkt.pts,
bytes = pkt.data.len(),
"🎤⚠️ dropping Opus microphone packet after decode failure"
);
return;
}
}
} else {
tracing::warn!(
target: "lesavka_server::audio",
pts = pkt.pts,
bytes = pkt.data.len(),
encoding = pkt.encoding,
"🎤⚠️ dropping upstream microphone packet with unsupported audio encoding"
);
return;
};
self.tap.feed(&pkt.data);
if !self.clock_aligned {
crate::media_timing::align_pipeline_to_session_clock(&self._pipe, pkt.pts);

View File

@ -57,6 +57,8 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000;
#[cfg(coverage)]
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
#[cfg(coverage)]
const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
#[cfg(coverage)]
#[repr(C)]
@ -139,3 +141,76 @@ struct ConfigfsSnapshot {
maxpacket: u32,
maxburst: u32,
}
#[cfg(coverage)]
struct MmapBuffer {
ptr: *mut u8,
len: usize,
}
#[cfg(coverage)]
struct UvcVideoStream {
buffers: Vec<MmapBuffer>,
frame_path: std::path::PathBuf,
latest_frame: Vec<u8>,
frame_max_bytes: usize,
}
#[cfg(coverage)]
impl UvcVideoStream {
fn new(_fd: i32) -> Self {
Self {
buffers: Vec::new(),
frame_path: frame_spool_path(),
latest_frame: EMPTY_MJPEG_FRAME.to_vec(),
frame_max_bytes: MAX_MJPEG_FRAME_BYTES,
}
}
fn refresh_latest_frame(&mut self) {
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
if stale && looks_like_mjpeg_frame(&self.latest_frame) {
return;
}
let max_frame_bytes = self.frame_payload_limit();
if let Ok(frame) = std::fs::read(&self.frame_path)
&& frame.len() <= max_frame_bytes
&& looks_like_mjpeg_frame(&frame)
{
self.latest_frame = frame;
} else if !looks_like_mjpeg_frame(&self.latest_frame) {
self.latest_frame = EMPTY_MJPEG_FRAME.to_vec();
}
}
fn frame_payload_limit(&self) -> usize {
self.buffers
.iter()
.map(|buffer| buffer.len)
.min()
.unwrap_or(MAX_MJPEG_FRAME_BYTES)
.min(self.frame_max_bytes)
}
fn frame_for_buffer(&self, buffer_len: usize) -> &[u8] {
if self.latest_frame.len() <= buffer_len && looks_like_mjpeg_frame(&self.latest_frame) {
&self.latest_frame
} else {
EMPTY_MJPEG_FRAME
}
}
}
#[cfg(coverage)]
fn frame_spool_path() -> std::path::PathBuf {
env::var("LESAVKA_UVC_FRAME_PATH")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/run/lesavka-uvc-frame.mjpg"))
}
#[cfg(coverage)]
fn looks_like_mjpeg_frame(frame: &[u8]) -> bool {
frame.len() > EMPTY_MJPEG_FRAME.len()
&& frame.starts_with(&[0xff, 0xd8])
&& frame.ends_with(&[0xff, 0xd9])
}

View File

@ -15,10 +15,14 @@ mod profile_offsets;
use mode_env::{current_uvc_mode, lookup_mode_offset_us};
pub use profile_offsets::{
FACTORY_HEVC_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_OFFSET_US,
FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_OPUS_AUDIO_OFFSET_US,
FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_PCM_AUDIO_OFFSET_US,
FACTORY_HEVC_VIDEO_MODE_OFFSETS_US, FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US,
FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US, FACTORY_HEVC_VIDEO_OFFSET_1920X1080_20_US,
FACTORY_HEVC_VIDEO_OFFSET_1920X1080_30_US, FACTORY_HEVC_VIDEO_OFFSET_US,
FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_OFFSET_US,
FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US,
FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_PCM_AUDIO_OFFSET_US,
FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_OFFSET_1280X720_20_US,
FACTORY_MJPEG_VIDEO_OFFSET_1280X720_30_US, FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_20_US,
FACTORY_MJPEG_VIDEO_OFFSET_1920X1080_30_US, FACTORY_MJPEG_VIDEO_OFFSET_US,
@ -40,6 +44,12 @@ const FACTORY_CONFIDENCE: &str = "factory";
const PREVIOUS_OFFSET_LIMIT_US: i64 = 500_000;
const OFFSET_LIMIT_US: i64 = 1_500_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalibrationMedia {
Audio,
Video,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CalibrationSnapshot {
profile: String,
@ -227,6 +237,37 @@ pub fn calibration_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("/var/lib/lesavka/calibration.toml"))
}
/// Resolve the startup playout offset for the current calibration profile.
///
/// Inputs: media kind plus process env. Output: microsecond offset selected
/// from profile-specific env or factory maps. Why: the upstream runtime starts
/// before the persisted calibration store is loaded, so it still needs the same
/// camera/audio profile fork used by the durable calibration state.
pub fn configured_playout_offset_us(media: CalibrationMedia) -> i64 {
let mode = current_uvc_mode();
let profile = current_profile();
let (media_name, factory_map, factory_scalar, stale) = match media {
CalibrationMedia::Audio => (
"AUDIO",
factory_audio_mode_offsets_us(&profile),
factory_audio_scalar_offset_us(&profile),
is_stale_audio_offset_us as fn(i64) -> bool,
),
CalibrationMedia::Video => (
"VIDEO",
factory_video_mode_offsets_us(&profile),
factory_video_scalar_offset_us(&profile),
is_stale_video_offset_us as fn(i64) -> bool,
),
};
configured_profile_offset_us(&profile, media_name, mode.as_deref(), stale)
.or_else(|| {
mode.as_deref()
.and_then(|mode| lookup_mode_offset_us(factory_map, mode))
})
.unwrap_or(factory_scalar)
}
/// Keeps `snapshot_from_env` explicit because it sits on calibration state, where persisted and factory offsets must stay auditable.
/// Inputs are the typed parameters; output is the return value or side effect.
fn snapshot_from_env() -> CalibrationSnapshot {

View File

@ -16,6 +16,14 @@ pub const FACTORY_HEVC_AUDIO_MODE_OFFSETS_US: &str =
"1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0";
pub const FACTORY_HEVC_VIDEO_MODE_OFFSETS_US: &str =
"1280x720@20=173852,1280x720@30=110000,1920x1080@20=160045,1920x1080@30=127952";
pub const FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US: &str = FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US;
pub const FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US: &str = FACTORY_HEVC_AUDIO_MODE_OFFSETS_US;
pub const FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US: &str = FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US;
pub const FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US: &str = FACTORY_HEVC_AUDIO_MODE_OFFSETS_US;
pub const FACTORY_MJPEG_PCM_AUDIO_OFFSET_US: i64 = FACTORY_MJPEG_AUDIO_OFFSET_US;
pub const FACTORY_HEVC_PCM_AUDIO_OFFSET_US: i64 = FACTORY_HEVC_AUDIO_OFFSET_US;
pub const FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US: i64 = FACTORY_MJPEG_AUDIO_OFFSET_US;
pub const FACTORY_HEVC_OPUS_AUDIO_OFFSET_US: i64 = FACTORY_HEVC_AUDIO_OFFSET_US;
// Direct UVC/UAC output-delay probes against the lab RC target showed a
// per-mode sync center for MJPEG/UVC video. This is output-path compensation,
// not a freshness buffer. The scalar fallback follows the default UVC mode.
@ -24,6 +32,8 @@ pub const FACTORY_HEVC_VIDEO_OFFSET_US: i64 = FACTORY_HEVC_VIDEO_OFFSET_1280X720
const MJPEG_PROFILE: &str = "mjpeg";
const HEVC_PROFILE: &str = "hevc";
const PCM_AUDIO_PROFILE: &str = "pcm";
const OPUS_AUDIO_PROFILE: &str = "opus";
use super::mode_env::{env_i64, env_mode_offset_us};
@ -40,24 +50,40 @@ pub(super) fn configured_profile_offset_us(
mode: Option<&str>,
is_stale_scalar: impl Fn(i64) -> bool,
) -> Option<i64> {
let profile_prefix = profile.to_ascii_uppercase();
let camera_profile = camera_profile_from_calibration_profile(profile);
let audio_profile = audio_profile_from_calibration_profile(profile);
let combined_prefix = format!(
"{}_{}",
camera_profile.to_ascii_uppercase(),
audio_profile.to_ascii_uppercase()
);
let camera_prefix = camera_profile.to_ascii_uppercase();
let combined_mode_map_name =
format!("LESAVKA_UPSTREAM_{combined_prefix}_{media}_PLAYOUT_MODE_OFFSETS_US");
let combined_scalar_name =
format!("LESAVKA_UPSTREAM_{combined_prefix}_{media}_PLAYOUT_OFFSET_US");
let profile_mode_map_name =
format!("LESAVKA_UPSTREAM_{profile_prefix}_{media}_PLAYOUT_MODE_OFFSETS_US");
let profile_scalar_name =
format!("LESAVKA_UPSTREAM_{profile_prefix}_{media}_PLAYOUT_OFFSET_US");
format!("LESAVKA_UPSTREAM_{camera_prefix}_{media}_PLAYOUT_MODE_OFFSETS_US");
let profile_scalar_name = format!("LESAVKA_UPSTREAM_{camera_prefix}_{media}_PLAYOUT_OFFSET_US");
let generic_mode_map_name = format!("LESAVKA_UPSTREAM_{media}_PLAYOUT_MODE_OFFSETS_US");
let generic_scalar_name = format!("LESAVKA_UPSTREAM_{media}_PLAYOUT_OFFSET_US");
let profile_offset = mode
let combined_offset = mode
.and_then(|mode| env_mode_offset_us(&combined_mode_map_name, mode))
.or_else(|| env_i64(&combined_scalar_name).filter(|offset| !is_stale_scalar(*offset)));
if combined_offset.is_some() {
return combined_offset;
}
let camera_offset = mode
.and_then(|mode| env_mode_offset_us(&profile_mode_map_name, mode))
.or_else(|| env_i64(&profile_scalar_name).filter(|offset| !is_stale_scalar(*offset)));
if profile_offset.is_some() {
return profile_offset;
if camera_offset.is_some() {
return camera_offset;
}
// Generic playout variables predate ingress profiles and were written by
// MJPEG installs. Do not let those stale maps silently override HEVC decode
// factory offsets; HEVC deployments can still opt in with the profile knobs.
if profile != MJPEG_PROFILE {
// MJPEG/PCM installs. Do not let those stale maps silently override HEVC or
// Opus factory offsets; new deployments can still opt in with profile knobs.
if camera_profile != MJPEG_PROFILE || audio_profile != PCM_AUDIO_PROFILE {
return None;
}
@ -67,49 +93,131 @@ pub(super) fn configured_profile_offset_us(
/// Resolve the active calibration profile from explicit and codec env hints.
///
/// Inputs: process environment. Output: normalized profile string. Why:
/// persisted calibration must follow the ingress codec, not just the UVC output
/// codec, because HEVC media pays decode/re-encode cost before reaching RCT.
/// Inputs: process environment. Output: normalized `camera+audio` profile
/// string. Why: persisted calibration must follow both the video ingress codec
/// and the microphone transport, because HEVC and Opus can each add timing cost
/// before media reaches the RCT.
pub(super) fn current_profile() -> String {
let camera_profile = current_camera_profile();
let audio_profile = current_audio_profile();
format!("{camera_profile}+{audio_profile}")
}
/// Resolve the camera side of the active calibration profile.
///
/// Inputs: process environment. Output: `mjpeg` or `hevc`. Why: the camera
/// ingress codec and microphone transport can now fork independently while
/// still sharing the old profile spellings for compatibility.
fn current_camera_profile() -> String {
std::env::var("LESAVKA_CALIBRATION_PROFILE")
.ok()
.and_then(|value| normalize_profile(&value))
.and_then(|value| normalize_camera_profile(&value))
.or_else(|| {
std::env::var("LESAVKA_UPLINK_CAMERA_CODEC")
.ok()
.and_then(|value| normalize_profile(&value))
.and_then(|value| normalize_camera_profile(&value))
})
.or_else(|| {
std::env::var("LESAVKA_CAM_CODEC")
.ok()
.and_then(|value| normalize_profile(&value))
.and_then(|value| normalize_camera_profile(&value))
})
.unwrap_or_else(|| MJPEG_PROFILE.to_string())
}
/// Resolve the microphone side of the active calibration profile.
///
/// Inputs: process environment. Output: `pcm` or `opus`. Why: Opus adds codec
/// delay before UAC, so it needs its own calibration namespace without erasing
/// the already measured PCM factory values.
fn current_audio_profile() -> String {
std::env::var("LESAVKA_CALIBRATION_PROFILE")
.ok()
.and_then(|value| normalize_audio_profile(&value))
.or_else(|| {
std::env::var("LESAVKA_CALIBRATION_AUDIO_CODEC")
.ok()
.and_then(|value| normalize_audio_profile(&value))
})
.or_else(|| {
std::env::var("LESAVKA_UPLINK_AUDIO_CODEC")
.ok()
.and_then(|value| normalize_audio_profile(&value))
})
.or_else(|| {
std::env::var("LESAVKA_AUDIO_CODEC")
.ok()
.and_then(|value| normalize_audio_profile(&value))
})
.unwrap_or_else(|| PCM_AUDIO_PROFILE.to_string())
}
/// Normalize user-facing codec spellings into calibration profile names.
///
/// Inputs: a codec/profile string. Output: `Some(profile)` for known camera
/// ingress profiles. Why: operators and install scripts use `h265`, `h.265`,
/// `mjpg`, and `jpeg` spellings interchangeably, but the stored calibration
/// profile must remain stable.
fn normalize_profile(value: &str) -> Option<String> {
match value.trim().to_ascii_lowercase().as_str() {
fn normalize_camera_profile(value: &str) -> Option<String> {
let normalized = value.trim().to_ascii_lowercase();
for token in normalized.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '.') {
match token {
"hevc" | "h265" | "h.265" => return Some(HEVC_PROFILE.to_string()),
"mjpeg" | "mjpg" | "jpeg" => return Some(MJPEG_PROFILE.to_string()),
_ => {}
}
}
match normalized.as_str() {
"hevc" | "h265" | "h.265" => Some(HEVC_PROFILE.to_string()),
"mjpeg" | "mjpg" | "jpeg" => Some(MJPEG_PROFILE.to_string()),
_ => None,
}
}
/// Normalize user-facing microphone transport spellings into calibration names.
///
/// Inputs: a codec/profile string. Output: `Some(profile)` for known upstream
/// audio transports. Why: Opus can be selected in the client UI while PCM
/// remains the known-good fallback, and each side needs a stable profile key.
fn normalize_audio_profile(value: &str) -> Option<String> {
let normalized = value.trim().to_ascii_lowercase();
for token in normalized.split(|ch: char| !ch.is_ascii_alphanumeric()) {
match token {
"opus" | "compressed" | "voice" => return Some(OPUS_AUDIO_PROFILE.to_string()),
"pcm" | "raw" | "s16le" | "uncompressed" => {
return Some(PCM_AUDIO_PROFILE.to_string());
}
_ => {}
}
}
match normalized.as_str() {
"opus" | "compressed" | "voice" => Some(OPUS_AUDIO_PROFILE.to_string()),
"pcm" | "raw" | "s16le" | "uncompressed" => Some(PCM_AUDIO_PROFILE.to_string()),
_ => None,
}
}
fn camera_profile_from_calibration_profile(profile: &str) -> String {
normalize_camera_profile(profile).unwrap_or_else(|| MJPEG_PROFILE.to_string())
}
fn audio_profile_from_calibration_profile(profile: &str) -> String {
normalize_audio_profile(profile).unwrap_or_else(|| PCM_AUDIO_PROFILE.to_string())
}
/// Return the factory per-mode audio offset map for a calibration profile.
///
/// Inputs: normalized profile. Output: comma-separated mode map. Why: keeping
/// audio maps profile-aware lets future decode paths diverge without changing
/// the persisted calibration schema again.
pub(super) fn factory_audio_mode_offsets_us(profile: &str) -> &'static str {
match profile {
HEVC_PROFILE => FACTORY_HEVC_AUDIO_MODE_OFFSETS_US,
_ => FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US,
let camera = camera_profile_from_calibration_profile(profile);
let audio = audio_profile_from_calibration_profile(profile);
match (camera.as_str(), audio.as_str()) {
(HEVC_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_HEVC_OPUS_AUDIO_MODE_OFFSETS_US,
(HEVC_PROFILE, _) => FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US,
(MJPEG_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_MJPEG_OPUS_AUDIO_MODE_OFFSETS_US,
_ => FACTORY_MJPEG_PCM_AUDIO_MODE_OFFSETS_US,
}
}
@ -119,7 +227,7 @@ pub(super) fn factory_audio_mode_offsets_us(profile: &str) -> &'static str {
/// where HEVC decode and MJPEG re-emission can shift sync, so each ingress
/// profile needs its own baked server-to-RCT center points.
pub(super) fn factory_video_mode_offsets_us(profile: &str) -> &'static str {
match profile {
match camera_profile_from_calibration_profile(profile).as_str() {
HEVC_PROFILE => FACTORY_HEVC_VIDEO_MODE_OFFSETS_US,
_ => FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US,
}
@ -131,9 +239,13 @@ pub(super) fn factory_video_mode_offsets_us(profile: &str) -> &'static str {
/// callers still need a safe default when no UVC mode is visible during early
/// process startup.
pub(super) fn factory_audio_scalar_offset_us(profile: &str) -> i64 {
match profile {
HEVC_PROFILE => FACTORY_HEVC_AUDIO_OFFSET_US,
_ => FACTORY_MJPEG_AUDIO_OFFSET_US,
let camera = camera_profile_from_calibration_profile(profile);
let audio = audio_profile_from_calibration_profile(profile);
match (camera.as_str(), audio.as_str()) {
(HEVC_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_HEVC_OPUS_AUDIO_OFFSET_US,
(HEVC_PROFILE, _) => FACTORY_HEVC_PCM_AUDIO_OFFSET_US,
(MJPEG_PROFILE, OPUS_AUDIO_PROFILE) => FACTORY_MJPEG_OPUS_AUDIO_OFFSET_US,
_ => FACTORY_MJPEG_PCM_AUDIO_OFFSET_US,
}
}
@ -144,7 +256,7 @@ pub(super) fn factory_audio_scalar_offset_us(profile: &str) -> i64 {
/// most common calibrated profile instead of silently borrowing stale MJPEG
/// values for HEVC.
pub(super) fn factory_video_scalar_offset_us(profile: &str) -> i64 {
match profile {
match camera_profile_from_calibration_profile(profile).as_str() {
HEVC_PROFILE => FACTORY_HEVC_VIDEO_OFFSET_US,
_ => FACTORY_MJPEG_VIDEO_OFFSET_US,
}

View File

@ -34,7 +34,30 @@ fn with_clean_offset_env(test: impl FnOnce()) {
("LESAVKA_CAM_FPS", None::<&str>),
("LESAVKA_CAM_CODEC", None::<&str>),
("LESAVKA_UPLINK_CAMERA_CODEC", None::<&str>),
("LESAVKA_UPLINK_AUDIO_CODEC", None::<&str>),
("LESAVKA_AUDIO_CODEC", None::<&str>),
("LESAVKA_CALIBRATION_AUDIO_CODEC", None::<&str>),
("LESAVKA_CALIBRATION_PROFILE", None::<&str>),
(
"LESAVKA_UPSTREAM_MJPEG_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US",
None::<&str>,
),
(
"LESAVKA_UPSTREAM_MJPEG_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US",
None::<&str>,
),
(
"LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US",
None::<&str>,
),
(
"LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US",
None::<&str>,
),
(
"LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_OFFSET_US",
None::<&str>,
),
(
"LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_MODE_OFFSETS_US",
None::<&str>,
@ -169,7 +192,7 @@ fn uvc_mode_detection_falls_back_to_camera_fields_after_invalid_modes() {
fn default_snapshot_uses_factory_mjpeg_calibration() {
with_clean_offset_env(|| {
let state = snapshot_from_env();
assert_eq!(state.profile, "mjpeg");
assert_eq!(state.profile, "mjpeg+pcm");
assert_eq!(state.default_audio_offset_us, 0);
assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US);
assert_eq!(state.source, "factory");
@ -188,7 +211,7 @@ fn hevc_profile_uses_hevc_factory_calibration_without_changing_mjpeg_defaults()
],
|| {
let state = snapshot_from_env();
assert_eq!(state.profile, "hevc");
assert_eq!(state.profile, "hevc+pcm");
assert_eq!(
state.factory_video_offset_us,
FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US
@ -203,6 +226,65 @@ fn hevc_profile_uses_hevc_factory_calibration_without_changing_mjpeg_defaults()
});
}
#[test]
fn opus_audio_profile_gets_its_own_factory_and_env_namespace() {
with_clean_offset_env(|| {
temp_env::with_vars(
[
("LESAVKA_UPLINK_CAMERA_CODEC", Some("hevc")),
("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")),
("LESAVKA_UVC_WIDTH", Some("1280")),
("LESAVKA_UVC_HEIGHT", Some("720")),
("LESAVKA_UVC_FPS", Some("30")),
],
|| {
let state = snapshot_from_env();
assert_eq!(state.profile, "hevc+opus");
assert_eq!(
state.factory_audio_offset_us,
FACTORY_HEVC_OPUS_AUDIO_OFFSET_US
);
assert_eq!(
state.factory_video_offset_us,
FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US
);
},
);
});
}
#[test]
fn opus_audio_profile_specific_map_does_not_overwrite_pcm_baseline() {
with_clean_offset_env(|| {
temp_env::with_vars(
[
("LESAVKA_UPLINK_CAMERA_CODEC", Some("hevc")),
("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")),
("LESAVKA_UVC_WIDTH", Some("1280")),
("LESAVKA_UVC_HEIGHT", Some("720")),
("LESAVKA_UVC_FPS", Some("30")),
(
"LESAVKA_UPSTREAM_HEVC_PCM_AUDIO_PLAYOUT_MODE_OFFSETS_US",
Some("1280x720@30=11111"),
),
(
"LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US",
Some("1280x720@30=22222"),
),
],
|| {
let state = snapshot_from_env();
assert_eq!(state.profile, "hevc+opus");
assert_eq!(state.default_audio_offset_us, 22_222);
assert_eq!(
FACTORY_HEVC_PCM_AUDIO_MODE_OFFSETS_US, FACTORY_HEVC_AUDIO_MODE_OFFSETS_US,
"PCM factory map remains the established golden baseline"
);
},
);
});
}
#[test]
fn hevc_profile_specific_env_map_overrides_generic_map() {
with_clean_offset_env(|| {
@ -223,7 +305,7 @@ fn hevc_profile_specific_env_map_overrides_generic_map() {
],
|| {
let state = snapshot_from_env();
assert_eq!(state.profile, "hevc");
assert_eq!(state.profile, "hevc+pcm");
assert_eq!(state.default_video_offset_us, 222_222);
assert_eq!(state.source, "env");
},
@ -247,7 +329,7 @@ fn hevc_profile_ignores_legacy_generic_mjpeg_map() {
],
|| {
let state = snapshot_from_env();
assert_eq!(state.profile, "hevc");
assert_eq!(state.profile, "hevc+pcm");
assert_eq!(
state.default_video_offset_us,
FACTORY_HEVC_VIDEO_OFFSET_1280X720_30_US
@ -831,7 +913,7 @@ fn restore_factory_rebuilds_current_profile_and_mode() {
..CalibrationRequest::default()
})
.expect("factory restore");
assert_eq!(factory.profile, "hevc");
assert_eq!(factory.profile, "hevc+pcm");
assert_eq!(
factory.active_video_offset_us,
FACTORY_HEVC_VIDEO_OFFSET_1280X720_20_US

View File

@ -1,11 +1,7 @@
#[cfg(not(coverage))]
const MEDIA_V2_DEFAULT_PLAYOUT_DELAY_MS: u64 = 20;
#[cfg(not(coverage))]
const MEDIA_V2_DEFAULT_MAX_LIVE_AGE_MS: u64 = 1_000;
#[cfg(not(coverage))]
const MEDIA_V2_MAX_MIXED_CAPTURE_SPAN_US: u64 = 250_000;
#[cfg(not(coverage))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct MediaV2BundleFacts {
has_audio: bool,
@ -16,7 +12,6 @@ struct MediaV2BundleFacts {
max_queue_age_ms: u32,
}
#[cfg(not(coverage))]
#[derive(Clone, Copy, Debug)]
struct MediaV2HandoffSchedule {
audio_due_at: Option<tokio::time::Instant>,
@ -38,7 +33,6 @@ struct MediaV2ScheduledVideo {
due_at: tokio::time::Instant,
}
#[cfg(not(coverage))]
/// Keeps `summarize_media_v2_bundle` explicit because it sits on relay RPC orchestration, where hardware failures must surface without stopping the server.
/// Inputs are the typed parameters; output is the return value or side effect.
fn summarize_media_v2_bundle(bundle: &UpstreamMediaBundle) -> Option<MediaV2BundleFacts> {
@ -75,7 +69,6 @@ fn summarize_media_v2_bundle(bundle: &UpstreamMediaBundle) -> Option<MediaV2Bund
})
}
#[cfg(not(coverage))]
/// Keeps `packet_audio_capture_pts_us` explicit because it sits on relay RPC orchestration, where hardware failures must surface without stopping the server.
/// Inputs are the typed parameters; output is the return value or side effect.
fn packet_audio_capture_pts_us(packet: &AudioPacket) -> u64 {
@ -86,7 +79,6 @@ fn packet_audio_capture_pts_us(packet: &AudioPacket) -> u64 {
}
}
#[cfg(not(coverage))]
/// Keeps `packet_video_capture_pts_us` explicit because it sits on relay RPC orchestration, where hardware failures must surface without stopping the server.
/// Inputs are the typed parameters; output is the return value or side effect.
fn packet_video_capture_pts_us(packet: &VideoPacket) -> u64 {
@ -97,7 +89,6 @@ fn packet_video_capture_pts_us(packet: &VideoPacket) -> u64 {
}
}
#[cfg(not(coverage))]
fn media_v2_playout_delay() -> Duration {
std::env::var("LESAVKA_UPSTREAM_V2_PLAYOUT_DELAY_MS")
.or_else(|_| std::env::var("LESAVKA_UPSTREAM_BUNDLED_PLAYOUT_DELAY_MS"))
@ -107,7 +98,6 @@ fn media_v2_playout_delay() -> Duration {
.unwrap_or_else(|| Duration::from_millis(MEDIA_V2_DEFAULT_PLAYOUT_DELAY_MS))
}
#[cfg(not(coverage))]
fn media_v2_max_live_age() -> Duration {
std::env::var("LESAVKA_UPSTREAM_V2_MAX_LIVE_AGE_MS")
.or_else(|_| std::env::var("LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS"))
@ -117,7 +107,6 @@ fn media_v2_max_live_age() -> Duration {
.unwrap_or_else(|| Duration::from_millis(MEDIA_V2_DEFAULT_MAX_LIVE_AGE_MS))
}
#[cfg(not(coverage))]
/// Keeps `media_v2_handoff_schedule` explicit because it sits on relay RPC orchestration, where hardware failures must surface without stopping the server.
/// Inputs are the typed parameters; output is the return value or side effect.
fn media_v2_handoff_schedule(
@ -159,7 +148,6 @@ fn media_v2_handoff_schedule(
})
}
#[cfg(not(coverage))]
/// Convert a negotiated video FPS into a microsecond frame step.
///
/// Inputs: frames per second from the camera config. Output: at least one
@ -177,7 +165,6 @@ fn media_v2_drop_late_plan(plan: &PlannedUpstreamPacket) -> bool {
plan.late_by >= media_v2_max_live_age()
}
#[cfg(not(coverage))]
/// Decide whether server-side HEVC recovery should hold a video packet.
///
/// Inputs: current recovery state, camera codec, and candidate video packet.
@ -196,7 +183,6 @@ fn media_v2_should_hold_hevc_video_for_recovery(
})
}
#[cfg(not(coverage))]
/// Report whether a video packet can repair a server-side HEVC reference gap.
///
/// Inputs: camera codec and candidate video packet. Output: true for HEVC IRAP

View File

@ -64,6 +64,10 @@ pub async fn run_server_output_delay_probe(
client_send_pts_us: pts_us,
client_queue_depth: 0,
client_queue_age_ms: 0,
encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32,
sample_rate: AUDIO_SAMPLE_RATE,
channels: AUDIO_CHANNELS as u32,
frame_duration_us: AUDIO_CHUNK_MS.saturating_mul(1_000) as u32,
});
if let Some(slot) = event_slot {
let monotonic_us = monotonic_us_since(start);

View File

@ -1,7 +1,7 @@
// server/src/paste.rs
#![forbid(unsafe_code)]
use anyhow::{Context, Result};
use anyhow::{Context, Result, ensure};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use std::{path::PathBuf, sync::Arc};
@ -23,6 +23,7 @@ pub fn decrypt(req: &PasteRequest) -> Result<String> {
if !req.encrypted {
anyhow::bail!("paste request must be encrypted");
}
ensure!(req.nonce.len() == 12, "paste nonce must be 12 bytes");
let key = load_key()?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let nonce = Nonce::from_slice(&req.nonce);
@ -97,8 +98,7 @@ fn unsupported_chars(chars: impl Iterator<Item = char>) -> (usize, String) {
}
fn load_key() -> Result<[u8; 32]> {
let raw = load_key_material()?;
decode_shared_key(&raw)
decode_shared_key(&load_key_material()?)
}
fn load_key_material() -> Result<String> {

View File

@ -7,10 +7,7 @@ use std::time::Duration;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio::time::Instant;
use crate::calibration::{
FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US, FACTORY_MJPEG_AUDIO_OFFSET_US,
FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US, FACTORY_MJPEG_VIDEO_OFFSET_US,
};
use crate::calibration::{CalibrationMedia, configured_playout_offset_us};
const TIMING_WINDOW_CAPACITY: usize = 240;
@ -362,68 +359,11 @@ fn upstream_playout_delay() -> Duration {
/// Keeps `playout_offset_us` explicit because it sits on server upstream media scheduling, where timing choices directly affect lip sync.
/// Inputs are the typed parameters; output is the return value or side effect.
fn playout_offset_us(kind: UpstreamMediaKind) -> i64 {
let (scalar_name, mode_map_name, factory_map, factory_offset_us) = match kind {
UpstreamMediaKind::Camera => (
"LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US",
"LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US",
FACTORY_MJPEG_VIDEO_MODE_OFFSETS_US,
FACTORY_MJPEG_VIDEO_OFFSET_US,
),
UpstreamMediaKind::Microphone => (
"LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US",
"LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US",
FACTORY_MJPEG_AUDIO_MODE_OFFSETS_US,
FACTORY_MJPEG_AUDIO_OFFSET_US,
),
let media = match kind {
UpstreamMediaKind::Camera => CalibrationMedia::Video,
UpstreamMediaKind::Microphone => CalibrationMedia::Audio,
};
let mode = current_uvc_mode();
mode.as_deref()
.and_then(|mode| env_mode_offset_us(mode_map_name, mode))
.or_else(|| env_i64(scalar_name))
.or_else(|| {
mode.as_deref()
.and_then(|mode| lookup_mode_offset_us(factory_map, mode))
})
.unwrap_or(factory_offset_us)
}
fn current_uvc_mode() -> Option<String> {
let width = env_u32("LESAVKA_UVC_WIDTH")?;
let height = env_u32("LESAVKA_UVC_HEIGHT")?;
let fps = env_u32("LESAVKA_UVC_FPS")
.or_else(|| {
env_u32("LESAVKA_UVC_INTERVAL")
.and_then(|interval| (interval > 0).then_some(10_000_000 / interval))
})?
.max(1);
Some(format!("{width}x{height}@{fps}"))
}
fn env_mode_offset_us(name: &str, mode: &str) -> Option<i64> {
std::env::var(name)
.ok()
.and_then(|map| lookup_mode_offset_us(&map, mode))
}
fn lookup_mode_offset_us(map: &str, mode: &str) -> Option<i64> {
map.split(',').find_map(|entry| {
let (key, value) = entry.trim().split_once('=')?;
(key.trim() == mode)
.then(|| value.trim().parse::<i64>().ok())
.flatten()
})
}
fn env_i64(name: &str) -> Option<i64> {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<i64>().ok())
}
fn env_u32(name: &str) -> Option<u32> {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<u32>().ok())
configured_playout_offset_us(media)
}
/// Keeps `apply_offset` explicit because it sits on server upstream media scheduling, where timing choices directly affect lip sync.

View File

@ -19,6 +19,15 @@ fn with_clean_offset_env(test: impl FnOnce()) {
("LESAVKA_UVC_HEIGHT", None::<&str>),
("LESAVKA_UVC_FPS", None::<&str>),
("LESAVKA_UVC_INTERVAL", None::<&str>),
("LESAVKA_CAM_CODEC", None::<&str>),
("LESAVKA_UPLINK_CAMERA_CODEC", None::<&str>),
("LESAVKA_UPLINK_AUDIO_CODEC", None::<&str>),
("LESAVKA_CALIBRATION_AUDIO_CODEC", None::<&str>),
("LESAVKA_CALIBRATION_PROFILE", None::<&str>),
(
"LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US",
None::<&str>,
),
],
test,
);
@ -78,6 +87,29 @@ fn runtime_prefers_mode_offset_map_over_scalar_fallback() {
});
}
#[test]
fn runtime_can_start_with_opus_specific_audio_calibration_profile() {
with_clean_offset_env(|| {
temp_env::with_vars(
[
("LESAVKA_UVC_WIDTH", Some("1280")),
("LESAVKA_UVC_HEIGHT", Some("720")),
("LESAVKA_UVC_FPS", Some("30")),
("LESAVKA_UPLINK_CAMERA_CODEC", Some("hevc")),
("LESAVKA_UPLINK_AUDIO_CODEC", Some("opus")),
(
"LESAVKA_UPSTREAM_HEVC_OPUS_AUDIO_PLAYOUT_MODE_OFFSETS_US",
Some("1280x720@30=24000"),
),
],
|| {
let runtime = UpstreamMediaRuntime::new();
assert_eq!(runtime.playout_offsets(), (110_000, 24_000));
},
);
});
}
#[test]
/// Keeps `runtime_records_client_and_sink_timing_for_upstream_snapshots` explicit because the blind client-to-RCT probe depends on this telemetry to explain freshness losses.
/// Inputs are paired camera/microphone timing samples plus sink handoff marks; output is a live snapshot with skew, queue, late, and freeze fields populated.

View File

@ -3,6 +3,8 @@ use crate::video_support::env_u32;
const DEFAULT_HEVC_JPEG_QUALITY: u32 = 72;
const DEFAULT_HEVC_SIZE_DROP_PCT: u32 = 45;
const DEFAULT_HEVC_MIN_REFERENCE_BYTES: u32 = 64 * 1024;
const DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES: u32 = 12;
const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92;
/// Resolve the JPEG quality used after HEVC decode.
///
@ -55,6 +57,80 @@ pub(super) fn min_reference_bytes() -> u32 {
.max(1)
}
/// Resolve the minimum compressed-payload byte variety for decoded MJPEG.
///
/// Inputs: optional `LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES`. Output:
/// distinct byte count threshold. Why: grey/black smear failures can arrive as
/// complete JPEG buffers, so the guard needs a conservative flat-payload check
/// in addition to simple size-collapse detection.
pub(super) fn min_payload_distinct_bytes() -> u32 {
env_u32(
"LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES",
DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES,
)
.clamp(1, 64)
}
/// Resolve the dominant-byte percentage that marks a payload as too flat.
///
/// Inputs: optional `LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT`, clamped to 50..=99.
/// Output: percentage threshold. Why: a single repeated byte occupying almost
/// all entropy-coded JPEG payload is more likely a damaged decode/transfer
/// artifact than useful webcam video, and freezing is the safer conference UX.
pub(super) fn dominant_byte_pct() -> u32 {
env_u32(
"LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT",
DEFAULT_HEVC_DOMINANT_BYTE_PCT,
)
.clamp(50, 99)
}
/// Return whether a decoded buffer looks like one complete JPEG image.
///
/// Inputs: decoded MJPEG bytes. Output: true when SOI, SOS, and EOI markers
/// are present. Why: incomplete JPEGs can still be non-empty buffers; freezing
/// them is safer than letting the UVC helper expose a partially decoded frame.
fn looks_like_complete_jpeg(bytes: &[u8]) -> bool {
bytes.len() >= 4
&& bytes.starts_with(&[0xff, 0xd8])
&& bytes.ends_with(&[0xff, 0xd9])
&& bytes.windows(2).any(|pair| pair == [0xff, 0xda])
}
/// Return whether one complete JPEG has an implausibly flat payload.
///
/// Inputs: decoded MJPEG bytes. Output: true for dominant-byte or very low
/// variety payloads. Why: the visible failure mode is often a grey/black slab
/// that is syntactically present but not meaningful webcam video.
fn suspiciously_flat_payload(bytes: &[u8]) -> bool {
if bytes.len() < min_reference_bytes() as usize / 4 {
return false;
}
let start = bytes
.windows(2)
.position(|pair| pair == [0xff, 0xda])
.map(|idx| (idx + 2).min(bytes.len()))
.unwrap_or_else(|| bytes.len().min(256));
let end = bytes.len().saturating_sub(2);
if end <= start || end - start < 512 {
return false;
}
let payload = &bytes[start..end];
let mut counts = [0u32; 256];
for byte in payload {
counts[*byte as usize] += 1;
}
let distinct = counts.iter().filter(|count| **count > 0).count() as u32;
let dominant = counts.iter().copied().max().unwrap_or(0) as u64;
let total = payload.len() as u64;
distinct < min_payload_distinct_bytes()
|| dominant.saturating_mul(100) >= total.saturating_mul(u64::from(dominant_byte_pct()))
}
/// Decide whether a decoded HEVC-to-MJPEG frame should be frozen out.
///
/// Inputs: byte length of the last successfully spooled decoded MJPEG and the
@ -71,6 +147,22 @@ pub(super) fn should_freeze_decoded_mjpeg(previous_bytes: u64, next_bytes: usize
next_bytes < threshold_bytes
}
/// Decide whether a decoded MJPEG payload should be frozen before UVC spool.
///
/// Inputs: byte length of the last successfully spooled decoded MJPEG and the
/// decoded MJPEG bytes. Output: true when the payload is incomplete, collapsed,
/// or suspiciously flat. Why: users prefer a short freeze over grey slabs,
/// mostly black frames, or torn images that conferencing apps may otherwise
/// display as if they were valid webcam frames.
pub(super) fn should_freeze_decoded_mjpeg_frame(previous_bytes: u64, decoded_mjpeg: &[u8]) -> bool {
if !freeze_on_size_drop_enabled() || previous_bytes < u64::from(min_reference_bytes()) {
return false;
}
!looks_like_complete_jpeg(decoded_mjpeg)
|| should_freeze_decoded_mjpeg(previous_bytes, decoded_mjpeg.len())
|| suspiciously_flat_payload(decoded_mjpeg)
}
#[cfg(test)]
mod tests {
#[test]
@ -117,4 +209,56 @@ mod tests {
},
);
}
#[test]
/// Verifies flat or incomplete HEVC-decoded MJPEG frames freeze out.
///
/// Inputs: synthetic JPEG-like payloads. Output: assertions only. Why:
/// the source-level guard should keep the same behavior as the root
/// taxonomy tests even when this helper is compiled inside the server crate.
fn freeze_guard_rejects_incomplete_or_flat_decoded_payloads() {
/// Build a minimal JPEG-like buffer for guard heuristic tests.
///
/// Inputs: entropy-coded payload bytes. Output: synthetic SOI/SOS/EOI
/// wrapper. Why: the guard tests need deterministic payload shape
/// without depending on slow image encoding.
fn jpeg_with_payload(payload: &[u8]) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8, 0xff, 0xda];
bytes.extend_from_slice(payload);
bytes.extend_from_slice(&[0xff, 0xd9]);
bytes
}
temp_env::with_vars(
[
("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")),
("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")),
("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")),
(
"LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES",
Some("12"),
),
("LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT", Some("92")),
],
|| {
let healthy_payload: Vec<u8> =
(0..120_000).map(|idx| (idx % 251) as u8).collect();
let flat_payload = vec![0x80; 120_000];
let mut truncated = jpeg_with_payload(&healthy_payload);
truncated.pop();
assert!(!super::should_freeze_decoded_mjpeg_frame(
200_000,
&jpeg_with_payload(&healthy_payload),
));
assert!(super::should_freeze_decoded_mjpeg_frame(
200_000,
&jpeg_with_payload(&flat_payload),
));
assert!(super::should_freeze_decoded_mjpeg_frame(
200_000, &truncated,
));
},
);
}
}

View File

@ -410,7 +410,8 @@ impl WebcamSink {
.last_decoded_mjpeg_bytes
.load(std::sync::atomic::Ordering::Relaxed);
let decoded_bytes = map.as_slice().len();
if hevc_mjpeg_guard::should_freeze_decoded_mjpeg(previous_bytes, decoded_bytes) {
if hevc_mjpeg_guard::should_freeze_decoded_mjpeg_frame(previous_bytes, map.as_slice())
{
warn!(
target:"lesavka_server::video",
previous_bytes,

View File

@ -1,43 +0,0 @@
[package]
name = "lesavka_testing"
version = "0.1.0"
edition = "2024"
publish = false
build = "build.rs"
[lib]
name = "lesavka_testing"
path = "src/lib.rs"
[dev-dependencies]
anyhow = "1.0"
async-stream = "0.3"
base64 = "0.22"
chrono = "0.4"
evdev = "0.13"
futures-util = "0.3"
libc = "0.2"
lesavka_client = { path = "../client" }
lesavka_common = { path = "../common" }
lesavka_server = { path = "../server" }
chacha20poly1305 = "0.10"
gstreamer = { version = "0.23", features = ["v1_22"] }
gstreamer-app = { version = "0.23", features = ["v1_22"] }
gstreamer-video = { version = "0.23", features = ["v1_22"] }
gtk = { version = "0.8", package = "gtk4", features = ["v4_6"] }
winit = "0.30"
serial_test = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
shell-escape = "0.1"
temp-env = { workspace = true }
tempfile = { workspace = true }
tokio = { version = "1.45", features = ["full", "macros", "rt-multi-thread", "sync", "time"] }
tokio-stream = "0.1"
tonic = { version = "0.13", features = ["transport"] }
tonic-reflection = "0.13"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] }
udev = "0.8"
v4l = "0.14"

View File

@ -1,10 +0,0 @@
//! Top-level integration testing crate for the lesavka workspace.
//!
//! Scope: keep cross-crate and contract-style tests out of package-local
//! `src/tests` and `tests` folders so CI has one integration test entrypoint.
//! Targets: the `testing/tests` suite plus coverage contracts consumed by the
//! Jenkins quality gate.
//! Why: a single top-level testing module is easier to scale, review, and
//! ratchet than ad-hoc integration tests spread across workspace members.
#![forbid(unsafe_code)]

View File

@ -1,77 +0,0 @@
//! Contract tests for client install-time security defaults.
//!
//! Scope: inspect the client installer shell contract without running it.
//! Targets: `scripts/install/client.sh`.
//! Why: secure-by-default relay transport depends on installing the server-issued
//! client cert bundle exactly where the desktop app auto-discovers it.
const CLIENT_INSTALL: &str = include_str!("../../scripts/install/client.sh");
#[test]
fn client_install_accepts_server_generated_tls_bundle() {
for expected in [
"LESAVKA_CLIENT_PKI_BUNDLE",
"CLIENT_PKI_DIR",
"ca.crt",
"client.crt",
"client.key",
"install_client_pki_bundle",
"fetch_client_pki_bundle",
"LESAVKA_CLIENT_PKI_SSH_SOURCE",
"LESAVKA_CLIENT_CAPTURE_DIR",
"theia:/etc/lesavka/lesavka-client-pki.tar.gz",
"Pictures/lesavka",
"HTTPS/mTLS relay connections will not work until this bundle is installed",
"TLS identity:",
"Captures:",
"$USER_HOME/.local/bin/lesavka-client",
"User PATH alias:",
] {
assert!(
CLIENT_INSTALL.contains(expected),
"client installer should include TLS bundle contract fragment {expected}"
);
}
assert!(
CLIENT_INSTALL.contains(".config/lesavka/pki"),
"client cert bundle should land in the same path the desktop app auto-loads"
);
assert!(
CLIENT_INSTALL.contains("0600"),
"client private key should be installed with private permissions"
);
assert!(
CLIENT_INSTALL.contains("scp -q -o BatchMode=yes"),
"client installer should auto-fetch the server enrollment bundle without hanging"
);
assert!(
CLIENT_INSTALL.contains("run_as_user mktemp"),
"auto-fetch destination should be owned by the user who runs scp"
);
}
#[test]
fn client_install_prefers_invoked_checkout_for_development_installs() {
for expected in [
"INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto}",
"resolve_source_checkout",
"Using local source checkout",
"set LESAVKA_INSTALL_SOURCE=ref",
"LESAVKA_INSTALL_SOURCE=local requested",
"unsupported LESAVKA_INSTALL_SOURCE",
"source_revision",
"+dirty",
] {
assert!(
CLIENT_INSTALL.contains(expected),
"client installer should preserve local-source install marker {expected}"
);
}
assert!(
CLIENT_INSTALL.find("resolve_source_checkout").unwrap()
< CLIENT_INSTALL
.find("cargo clean && cargo build --release")
.unwrap(),
"client installer should resolve source before building"
);
}

View File

@ -1,48 +0,0 @@
//! Integration coverage for client process startup behavior.
//!
//! Scope: launch the real `lesavka-client` binary and validate guarded startup
//! behavior when desktop runtime prerequisites are missing.
//! Targets: `client/src/main.rs`.
//! Why: process-level startup failures should stay deterministic and
//! user-readable.
use serial_test::serial;
use std::path::{Path, PathBuf};
use std::process::Command;
fn candidate_dirs() -> Vec<PathBuf> {
let exe = std::env::current_exe().expect("current exe path");
let mut dirs = Vec::new();
if let Some(parent) = exe.parent() {
dirs.push(parent.to_path_buf());
if let Some(grand) = parent.parent() {
dirs.push(grand.to_path_buf());
}
}
dirs.push(PathBuf::from("target/debug"));
dirs.push(PathBuf::from("target/llvm-cov-target/debug"));
dirs
}
fn find_binary(name: &str) -> Option<PathBuf> {
candidate_dirs()
.into_iter()
.map(|dir| dir.join(name))
.find(|path| path.exists() && path.is_file())
}
#[test]
#[serial]
fn client_binary_fails_fast_without_runtime_dir() {
let Some(bin) = find_binary("lesavka-client") else {
return;
};
let status = Command::new(Path::new(&bin))
.env_remove("XDG_RUNTIME_DIR")
.env_remove("LESAVKA_HEADLESS")
.status()
.expect("spawn lesavka-client");
assert!(!status.success(), "startup should fail without runtime dir");
}

View File

@ -1,55 +0,0 @@
//! Module-path coverage for client-side H.264 decoder selection.
//!
//! Scope: include the client decoder selection helper directly.
//! Targets: `client/src/video_support.rs`.
//! Why: operator decoder overrides should fall back cleanly on machines with
//! different GStreamer plugin sets.
#[path = "../../client/src/video_support.rs"]
mod video_support;
use serial_test::serial;
use temp_env::with_var;
#[test]
#[serial]
fn decoder_override_accepts_decodebin_without_factory_lookup() {
with_var("LESAVKA_H264_DECODER", Some("decodebin"), || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
});
}
#[test]
#[serial]
fn decoder_override_accepts_buildable_element() {
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(video_support::pick_h264_decoder(), "fakesink");
});
}
#[test]
#[serial]
fn decoder_override_ignores_blank_or_unknown_values() {
with_var("LESAVKA_H264_DECODER", Some(" "), || {
assert!(!video_support::pick_h264_decoder().trim().is_empty());
});
with_var("LESAVKA_H264_DECODER", Some("not-a-real-decoder"), || {
assert!(!video_support::pick_h264_decoder().trim().is_empty());
});
}
#[test]
#[serial]
#[cfg(coverage)]
fn decoder_selection_falls_back_when_no_factory_can_build() {
with_var("TEST_DISABLE_H264_DECODER_FACTORY", Some("1"), || {
with_var("LESAVKA_H264_DECODER", Some("fakesink"), || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
});
});
with_var("TEST_FAIL_GST_INIT", Some("1"), || {
with_var("LESAVKA_H264_DECODER", None::<&str>, || {
assert_eq!(video_support::pick_h264_decoder(), "decodebin");
});
});
}

View File

@ -1,14 +0,0 @@
//! Integration coverage for the common CLI binary entrypoint.
//!
//! Scope: include the common CLI bin source and execute `main` directly.
//! Targets: `common/src/bin/cli.rs`.
//! Why: keep binary entrypoint coverage in the centralized testing crate.
mod common_cli_binary {
include!(env!("LESAVKA_COMMON_CLI_BIN_SRC"));
#[test]
fn cli_main_executes_without_panicking() {
main();
}
}

View File

@ -1,12 +0,0 @@
//! Integration coverage for the common CLI entrypoint contract.
//!
//! Scope: execute the public common CLI helper from the centralized testing
//! crate.
//! Targets: `common/src/lib.rs`.
//! Why: this keeps even tiny user-facing helpers represented in cross-crate
//! contract coverage without package-local integration tests.
#[test]
fn run_cli_executes_without_panicking() {
lesavka_common::run_cli();
}

View File

@ -1,10 +1,10 @@
//! Include-based coverage for relay control CLI parsing and helpers.
//!
//! Scope: include `client/src/bin/lesavka-relayctl.rs` and exercise helper
//! branches that do not require a live relay server.
//! Targets: `client/src/bin/lesavka-relayctl.rs`.
//! Why: relay power recovery controls need parser coverage without depending on
//! a live relay endpoint.
// Include-based coverage for relay control CLI parsing and helpers.
//
// Scope: include `client/src/bin/lesavka-relayctl.rs` and exercise helper
// branches that do not require a live relay server.
// Targets: `client/src/bin/lesavka-relayctl.rs`.
// Why: relay power recovery controls need parser coverage without depending on
// a live relay endpoint.
#[allow(warnings)]
mod relayctl_binary {

View File

@ -1,9 +1,9 @@
//! Process-level coverage for relay control CLI argument handling.
//!
//! Scope: launch the real `lesavka-relayctl` binary for argument-only paths.
//! Targets: `client/src/bin/lesavka-relayctl.rs`.
//! Why: CLI-only failures should stay fast and local instead of retrying a bad
//! network endpoint.
// Process-level coverage for relay control CLI argument handling.
//
// Scope: launch the real `lesavka-relayctl` binary for argument-only paths.
// Targets: `client/src/bin/lesavka-relayctl.rs`.
// Why: CLI-only failures should stay fast and local instead of retrying a bad
// network endpoint.
use serial_test::serial;
use std::path::{Path, PathBuf};

View File

@ -0,0 +1,14 @@
// Integration coverage for the common CLI binary entrypoint.
//
// Scope: include the common CLI bin source and execute `main` directly.
// Targets: `common/src/bin/cli.rs`.
// Why: keep binary entrypoint coverage in the root test harness crate.
mod common_cli_binary {
include!(env!("LESAVKA_COMMON_CLI_BIN_SRC"));
#[test]
fn cli_main_executes_without_panicking() {
main();
}
}

View File

@ -0,0 +1,12 @@
// Integration coverage for the common CLI entrypoint contract.
//
// Scope: execute the public common CLI helper from the centralized test harness
// crate.
// Targets: `common/src/lib.rs`.
// Why: this keeps even tiny user-facing helpers represented in cross-crate
// contract coverage without package-local integration tests.
#[test]
fn run_cli_executes_without_panicking() {
lesavka_common::run_cli();
}

View File

@ -1,12 +1,12 @@
//! Integration coverage for the handshake and camera-selection contract.
//!
//! Scope: exercise the real client handshake against a local server and the
//! server camera selection logic that feeds the handshake response.
//! Targets: `client/src/handshake.rs`, `server/src/handshake.rs`, and
//! `server/src/camera.rs`.
//! Why: the handshake path is the narrow entrypoint that decides whether the
//! client and server agree on output mode, so it deserves a centralized
//! contract test.
// Integration coverage for the handshake and camera-selection contract.
//
// Scope: exercise the real client handshake against a local server and the
// server camera selection logic that feeds the handshake response.
// Targets: `client/src/handshake.rs`, `server/src/handshake.rs`, and
// `server/src/camera.rs`.
// Why: the handshake path is the narrow entrypoint that decides whether the
// client and server agree on output mode, so it deserves a centralized
// contract test.
use lesavka_client::handshake::{HandshakeProbe, PeerCaps, negotiate, probe};
use lesavka_common::lesavka::{

View File

@ -0,0 +1,91 @@
// API/security contract for unauthenticated relay RPC rejection.
//
// Scope: preserve the security boundary between transport authentication and
// handler execution for media, HID, and paste RPCs.
// Targets: server entrypoint and relay helper modules.
// Why: without TLS/mTLS on the server builder, any client could stream media or
// HID reports into the remote controlled target.
const ENTRYPOINT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/entrypoint.rs"
));
const SERVER_SECURITY: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/security.rs"
));
const INPUT_RPC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service/input_stream_rpc.rs"
));
const CAMERA_RPC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service/camera_stream_rpc.rs"
));
const MICROPHONE_RPC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service/microphone_stream_rpc.rs"
));
const RPC_HELPERS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/rpc_helpers.rs"
));
#[test]
fn server_installs_tls_before_exposing_relay_services() {
let tls_idx = ENTRYPOINT
.find("security::server_tls_config()?")
.expect("entrypoint should request TLS config");
let relay_idx = ENTRYPOINT
.find(".add_service(RelayServer::new(handler))")
.expect("entrypoint should expose relay service");
assert!(
tls_idx < relay_idx,
"TLS must be configured before media/HID RPC services are exposed"
);
assert!(ENTRYPOINT.contains("server = server.tls_config(tls)?"));
}
#[test]
fn production_mtls_requires_a_client_ca_unless_operator_explicitly_opts_out() {
assert!(SERVER_SECURITY.contains("client_ca_root"));
assert!(SERVER_SECURITY.contains("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL"));
assert!(
SERVER_SECURITY.contains("TLS enabled with required client certificate authentication"),
"required client certificate auth should be the normal CA-backed path"
);
}
#[test]
fn powerful_rpc_handlers_depend_on_transport_auth_boundary() {
for (name, source, lease_marker) in [
("keyboard", INPUT_RPC, "capture_power.acquire_session()"),
("mouse", INPUT_RPC, "capture_power.acquire_session()"),
("camera", CAMERA_RPC, "activate_camera()"),
("microphone", MICROPHONE_RPC, "reserve_microphone_sink"),
] {
assert!(
source.contains(lease_marker),
"{name} RPC should continue using runtime leases after transport auth"
);
assert!(
!source.contains("LESAVKA_ALLOW_INSECURE"),
"{name} RPC should not override transport security inside handler code"
);
}
}
#[test]
fn unauthenticated_paste_payloads_map_to_unauthenticated_status() {
assert!(RPC_HELPERS.contains("paste::decrypt(&req)"));
assert!(RPC_HELPERS.contains("Status::unauthenticated"));
assert!(
RPC_HELPERS
.find("paste::decrypt(&req)")
.expect("decrypt marker")
< RPC_HELPERS
.find("paste::type_text")
.expect("type text marker"),
"paste payloads must authenticate/decrypt before HID typing begins"
);
}

View File

@ -1,9 +1,9 @@
//! Integration coverage for server main RPC handler branches.
//!
//! Scope: include `server/src/main.rs` and exercise additional RPC paths that
//! are awkward to hit from process-level tests.
//! Targets: `server/src/main.rs`.
//! Why: keep handler-side error/reply behavior stable without HID hardware.
// Integration coverage for server main RPC handler branches.
//
// Scope: include `server/src/main.rs` and exercise additional RPC paths that
// are awkward to hit from process-level tests.
// Targets: `server/src/main.rs`.
// Why: keep handler-side error/reply behavior stable without HID hardware.
#[allow(warnings)]
mod server_main_rpc {

View File

@ -1,9 +1,9 @@
//! RPC reset coverage for server main USB recovery replies.
//!
//! Scope: include `server/src/main.rs` and exercise reset RPC edge replies.
//! Targets: `server/src/main.rs`.
//! Why: USB reset is an operator recovery path, so failed HID reopen behavior
//! needs deterministic coverage without requiring real gadget hardware.
// RPC reset coverage for server main USB recovery replies.
//
// Scope: include `server/src/main.rs` and exercise reset RPC edge replies.
// Targets: `server/src/main.rs`.
// Why: USB reset is an operator recovery path, so failed HID reopen behavior
// needs deterministic coverage without requiring real gadget hardware.
#[allow(warnings)]
mod server_main_rpc_reset {

View File

@ -1,10 +1,10 @@
//! Integration coverage for server state-oriented RPC handler branches.
//!
//! Scope: include `server/src/main.rs` and exercise calibration, capture-power,
//! and upstream-sync RPC surfaces.
//! Targets: `server/src/main.rs`.
//! Why: these RPCs expose live operational state, so tests should guard reply
//! shapes without requiring gadget, HID, or capture hardware.
// Integration coverage for server state-oriented RPC handler branches.
//
// Scope: include `server/src/main.rs` and exercise calibration, capture-power,
// and upstream-sync RPC surfaces.
// Targets: `server/src/main.rs`.
// Why: these RPCs expose live operational state, so tests should guard reply
// shapes without requiring gadget, HID, or capture hardware.
#[allow(warnings)]
mod server_main_state_rpc {
@ -174,7 +174,7 @@ mod server_main_state_rpc {
})
.expect("initial calibration")
.into_inner();
assert_eq!(initial.profile, "mjpeg");
assert_eq!(initial.profile, "mjpeg+pcm");
assert_eq!(initial.active_audio_offset_us, 0);
let initial_video_offset_us = initial.active_video_offset_us;

View File

@ -1,16 +1,19 @@
//! End-to-end server coverage for upstream microphone media streams.
//!
//! Scope: run a local gRPC server and push synthetic client microphone packets
//! through the public `StreamMicrophone` RPC.
//! Targets: `server/src/main.rs`, `server/src/audio.rs`.
//! Why: upstream audio should surface sink failures, supersession, and normal
//! packet delivery without requiring ALSA hardware in CI.
// End-to-end server coverage for upstream microphone media streams.
//
// Scope: run a local gRPC server and push synthetic client microphone packets
// through the public `StreamMicrophone` RPC.
// Targets: `server/src/main.rs`, `server/src/audio.rs`.
// Why: upstream audio should surface sink failures, supersession, and normal
// packet delivery without requiring ALSA hardware in CI.
#[cfg(coverage)]
#[allow(warnings)]
mod server_upstream_media_audio {
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
include!("../support/server_upstream_media_harness.rs");
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/helpers/support/server_upstream_media_harness.rs"
));
use serial_test::serial;
use temp_env::with_var;
@ -126,3 +129,31 @@ mod server_upstream_media_audio {
});
}
}
#[cfg(not(coverage))]
mod server_upstream_media_audio_normal_mode {
const MICROPHONE_RPC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service/microphone_stream_rpc.rs"
));
const RELAY_TRAIT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service.rs"
));
const AUDIO_SINK: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/audio/voice_input.rs"
));
const RUNTIME: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/upstream_media_runtime.rs"
));
#[test]
fn microphone_rpc_stays_wired_to_voice_sink_runtime() {
assert!(RELAY_TRAIT.contains("stream_microphone"));
assert!(MICROPHONE_RPC.contains("open_voice_with_retry"));
assert!(AUDIO_SINK.contains("LESAVKA_UAC_BUFFER_TIME_US"));
assert!(RUNTIME.contains("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US"));
}
}

View File

@ -1,16 +1,19 @@
//! End-to-end server coverage for bundled upstream webcam/microphone media.
//!
//! Scope: run a local gRPC server and send a synthetic HEVC video packet with
//! the audio that should play beside it through `StreamWebcamMedia`.
//! Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`.
//! Why: client transport should reach the server as one timestamped media unit
//! so the relay can preserve sync before final RCT playout.
// End-to-end server coverage for bundled upstream webcam/microphone media.
//
// Scope: run a local gRPC server and send a synthetic HEVC video packet with
// the audio that should play beside it through `StreamWebcamMedia`.
// Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`.
// Why: client transport should reach the server as one timestamped media unit
// so the relay can preserve sync before final RCT playout.
#[cfg(coverage)]
#[allow(warnings)]
mod server_upstream_media_bundle {
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
include!("../support/server_upstream_media_harness.rs");
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/helpers/support/server_upstream_media_harness.rs"
));
use serial_test::serial;
use temp_env::with_var;
@ -59,6 +62,7 @@ mod server_upstream_media_bundle {
}],
audio_sample_rate: 48_000,
audio_channels: 2,
audio_encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32,
video_width: 1920,
video_height: 1080,
video_fps: 30,
@ -155,6 +159,7 @@ mod server_upstream_media_bundle {
],
audio_sample_rate: 48_000,
audio_channels: 2,
audio_encoding: lesavka_common::lesavka::AudioEncoding::PcmS16le as i32,
video_width: 1920,
video_height: 1080,
video_fps: 30,
@ -206,3 +211,27 @@ mod server_upstream_media_bundle {
});
}
}
#[cfg(not(coverage))]
mod server_upstream_media_bundle_normal_mode {
const RELAY_TRAIT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service.rs"
));
const RELAY_RPC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service/upstream_media_rpc.rs"
));
const RELAY_LIFECYCLE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_stream_lifecycle.rs"
));
#[test]
fn bundled_rpc_preserves_one_media_unit_at_server_ingress() {
assert!(RELAY_TRAIT.contains("stream_webcam_media"));
assert!(RELAY_RPC.contains("UpstreamMediaBundle"));
assert!(RELAY_LIFECYCLE.contains("client_capture_pts_us"));
assert!(RELAY_RPC.contains("record_client_timing"));
}
}

View File

@ -1,16 +1,19 @@
//! End-to-end server coverage for shared upstream media stream helpers.
//!
//! Scope: run local helper-level and output-delay probe checks around the public
//! upstream media RPC implementation.
//! Targets: `server/src/main.rs`, `server/src/output_delay_probe.rs`.
//! Why: the coverage harness should keep freshness decisions and probe plumbing
//! stable without physical UVC, HDMI, or ALSA hardware in CI.
// End-to-end server coverage for shared upstream media stream helpers.
//
// Scope: run local helper-level and output-delay probe checks around the public
// upstream media RPC implementation.
// Targets: `server/src/main.rs`, `server/src/output_delay_probe.rs`.
// Why: the coverage harness should keep freshness decisions and probe plumbing
// stable without physical UVC, HDMI, or ALSA hardware in CI.
#[cfg(coverage)]
#[allow(warnings)]
mod server_upstream_media {
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
include!("../support/server_upstream_media_harness.rs");
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/helpers/support/server_upstream_media_harness.rs"
));
use serial_test::serial;
use temp_env::with_var;
@ -286,3 +289,34 @@ mod server_upstream_media {
});
}
}
#[cfg(not(coverage))]
mod server_upstream_media_normal_mode {
const RELAY_COVERAGE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service_coverage.rs"
));
const FRESHNESS_HELPERS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_service_coverage/freshness_helpers.rs"
));
#[test]
fn upstream_media_coverage_helpers_stay_isolated_but_discoverable() {
assert!(
RELAY_COVERAGE.contains("freshness_helpers"),
"coverage module should expose freshness helper coverage"
);
for helper in [
"coverage_playable_plan",
"coverage_audio_plan_from_decision",
"coverage_video_plan_from_decision",
"coverage_audio_master_ready",
] {
assert!(
FRESHNESS_HELPERS.contains(helper),
"missing upstream coverage helper {helper}"
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More