release: ship 0.22.1 media transport hardening
This commit is contained in:
parent
da7a49bc8c
commit
4c6010ece6
8
Cargo.lock
generated
8
Cargo.lock
generated
@ -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",
|
||||
|
||||
663
Cargo.toml
663
Cargo.toml
@ -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
78
Jenkinsfile
vendored
@ -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}"
|
||||
}
|
||||
|
||||
@ -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")
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.21.17"
|
||||
version = "0.22.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
193
client/src/input/audio_codec.rs
Normal file
193
client/src/input/audio_codec.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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!(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(µphone_channel_toggle, 92);
|
||||
stabilize_button(µphone_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(µphone_combo);
|
||||
microphone_selectors.append(&mic_gain_scale);
|
||||
let microphone_toggle_group = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
microphone_toggle_group.append(µphone_channel_toggle);
|
||||
microphone_toggle_group.append(&noise_suppression_toggle);
|
||||
attach_device_control_row(
|
||||
&media_grid,
|
||||
2,
|
||||
µphone_channel_toggle,
|
||||
µphone_toggle_group,
|
||||
µphone_selectors,
|
||||
µphone_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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -149,6 +149,10 @@ pub fn install_css(window: >k::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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.21.17"
|
||||
version = "0.22.1"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
294
common/src/audio_transport.rs
Normal file
294
common/src/audio_transport.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 |
|
||||
|
||||
39
docs/opus-transport-testing.md
Normal file
39
docs/opus-transport-testing.md
Normal 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
228
scripts/ci/baremetal_lab_gate.sh
Executable 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
136
scripts/ci/daily_master_gate.sh
Executable 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}"
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[@]}"
|
||||
|
||||
@ -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': {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[@]}" \
|
||||
|
||||
211
scripts/daemon/lesavka-recovery-ladder.sh
Executable file
211
scripts/daemon/lesavka-recovery-ladder.sh
Executable 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 "$@"
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.21.17"
|
||||
version = "0.22.1"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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)]
|
||||
|
||||
190
server/src/audio/opus_decode.rs
Normal file
190
server/src/audio/opus_decode.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
@ -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)]
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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 {
|
||||
@ -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};
|
||||
14
tests/api/common/cli/common_cli_binary_contract.rs
Normal file
14
tests/api/common/cli/common_cli_binary_contract.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
12
tests/api/common/cli/common_cli_contract.rs
Normal file
12
tests/api/common/cli/common_cli_contract.rs
Normal 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();
|
||||
}
|
||||
@ -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::{
|
||||
91
tests/api/server/auth/server_auth_rpc_contract.rs
Normal file
91
tests/api/server/auth/server_auth_rpc_contract.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
@ -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 {
|
||||
@ -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;
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user