sync(server-rc): stabilize mode matrix calibration
This commit is contained in:
parent
d33c5ebbaf
commit
1854018e2a
@ -123,12 +123,12 @@ path.
|
||||
- [x] Add dense server-generated smoothness evidence on the normal UVC/UAC
|
||||
path: per-frame video continuity watermark, quiet audio pilot, cadence
|
||||
jitter, duplicate/missing frame estimates, and low-RMS audio gap counts.
|
||||
- [ ] Keep UI/profile controls authoritative for UVC output profiles beyond
|
||||
`640x480@20`; validate `1280x720@30` and `1920x1080@20/30` after sync is
|
||||
- [ ] Keep UI/profile controls authoritative for webcam-backed UVC output
|
||||
profiles; validate `1280x720@20/30` and `1920x1080@20/30` after sync is
|
||||
locked.
|
||||
- [x] Add a server-to-RC mode-matrix harness so the same sync/freshness/
|
||||
smoothness contract can be run against `640x480@20`, `1280x720@30`,
|
||||
`1920x1080@20`, and `1920x1080@30`.
|
||||
smoothness contract can be run against the core Logitech-backed modes:
|
||||
`1280x720@20`, `1280x720@30`, `1920x1080@20`, and `1920x1080@30`.
|
||||
- [ ] Run the mode matrix on Theia/Tethys and record per-mode static delay
|
||||
center points before changing the normal advertised profiles.
|
||||
- [ ] Keep the UI +/-5ms calibration nudges available as small post-baseline
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_client"
|
||||
version = "0.19.14"
|
||||
version = "0.19.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@ -1686,7 +1686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_common"
|
||||
version = "0.19.14"
|
||||
version = "0.19.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@ -1698,7 +1698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_server"
|
||||
version = "0.19.14"
|
||||
version = "0.19.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.19.14"
|
||||
version = "0.19.15"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -68,7 +68,7 @@ where
|
||||
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
|
||||
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
println!(
|
||||
"Usage: lesavka-sync-analyze <capture.mkv> [--json] [--report-dir <dir>] [--event-width-codes 1,2,1,3]"
|
||||
"Usage: lesavka-sync-analyze <capture.mkv> [--json] [--report-dir <dir>] [--event-width-codes 1,2,1,3] [--analysis-window-s START:END]"
|
||||
);
|
||||
std::process::exit(0);
|
||||
}
|
||||
@ -111,6 +111,48 @@ where
|
||||
options.event_width_codes = parse_event_width_codes(raw_codes)?;
|
||||
continue;
|
||||
}
|
||||
if arg == "--analysis-window-s" {
|
||||
let Some(raw_window) = iter.next() else {
|
||||
bail!("--analysis-window-s requires START:END seconds");
|
||||
};
|
||||
parse_analysis_window(&raw_window, &mut options)?;
|
||||
continue;
|
||||
}
|
||||
if let Some(raw_window) = arg.strip_prefix("--analysis-window-s=") {
|
||||
if raw_window.is_empty() {
|
||||
bail!("--analysis-window-s requires START:END seconds");
|
||||
}
|
||||
parse_analysis_window(raw_window, &mut options)?;
|
||||
continue;
|
||||
}
|
||||
if arg == "--analysis-start-s" {
|
||||
let Some(raw_start) = iter.next() else {
|
||||
bail!("--analysis-start-s requires seconds");
|
||||
};
|
||||
options.analysis_start_s = Some(parse_analysis_seconds(&raw_start, "analysis start")?);
|
||||
continue;
|
||||
}
|
||||
if let Some(raw_start) = arg.strip_prefix("--analysis-start-s=") {
|
||||
if raw_start.is_empty() {
|
||||
bail!("--analysis-start-s requires seconds");
|
||||
}
|
||||
options.analysis_start_s = Some(parse_analysis_seconds(raw_start, "analysis start")?);
|
||||
continue;
|
||||
}
|
||||
if arg == "--analysis-end-s" {
|
||||
let Some(raw_end) = iter.next() else {
|
||||
bail!("--analysis-end-s requires seconds");
|
||||
};
|
||||
options.analysis_end_s = Some(parse_analysis_seconds(&raw_end, "analysis end")?);
|
||||
continue;
|
||||
}
|
||||
if let Some(raw_end) = arg.strip_prefix("--analysis-end-s=") {
|
||||
if raw_end.is_empty() {
|
||||
bail!("--analysis-end-s requires seconds");
|
||||
}
|
||||
options.analysis_end_s = Some(parse_analysis_seconds(raw_end, "analysis end")?);
|
||||
continue;
|
||||
}
|
||||
if capture_path.is_some() {
|
||||
bail!("unexpected extra argument `{arg}`");
|
||||
}
|
||||
@ -150,6 +192,36 @@ fn parse_event_width_codes(raw: &str) -> Result<Vec<u32>> {
|
||||
Ok(codes)
|
||||
}
|
||||
|
||||
#[cfg(any(not(coverage), test))]
|
||||
fn parse_analysis_window(raw: &str, options: &mut SyncAnalysisOptions) -> Result<()> {
|
||||
let Some((start, end)) = raw.split_once(':') else {
|
||||
bail!("--analysis-window-s requires START:END seconds");
|
||||
};
|
||||
options.analysis_start_s = if start.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parse_analysis_seconds(start, "analysis start")?)
|
||||
};
|
||||
options.analysis_end_s = if end.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parse_analysis_seconds(end, "analysis end")?)
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(not(coverage), test))]
|
||||
fn parse_analysis_seconds(raw: &str, label: &str) -> Result<f64> {
|
||||
let value = raw
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.with_context(|| format!("parsing {label} `{raw}`"))?;
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
bail!("{label} must be a finite non-negative timestamp");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn format_human_report(
|
||||
capture_path: &std::path::Path,
|
||||
@ -358,11 +430,20 @@ mod tests {
|
||||
assert_eq!(args.options.event_width_codes, vec![1, 2, 1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_accepts_analysis_window() {
|
||||
let args = parse_args(["capture.mkv", "--analysis-window-s", "8.25:26.5"]).expect("args");
|
||||
assert_eq!(args.options.analysis_start_s, Some(8.25));
|
||||
assert_eq!(args.options.analysis_end_s, Some(26.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_rejects_extra_positional_arguments() {
|
||||
assert!(parse_args(["one.mkv", "two.mkv"]).is_err());
|
||||
assert!(parse_args(["one.mkv", "--event-width-codes", ""]).is_err());
|
||||
assert!(parse_args(["one.mkv", "--event-width-codes", "0"]).is_err());
|
||||
assert!(parse_args(["one.mkv", "--analysis-window-s", "wat:10"]).is_err());
|
||||
assert!(parse_args(["one.mkv", "--analysis-start-s", "-1"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -196,7 +196,9 @@ fn discover_camera_modes_for(camera: &str) -> Vec<CameraMode> {
|
||||
pub fn lesavka_supported_camera_modes() -> Vec<CameraMode> {
|
||||
vec![
|
||||
CameraMode::new(1920, 1080, 30),
|
||||
CameraMode::new(1920, 1080, 20),
|
||||
CameraMode::new(1280, 720, 30),
|
||||
CameraMode::new(1280, 720, 20),
|
||||
]
|
||||
}
|
||||
|
||||
@ -224,7 +226,7 @@ pub fn parse_supported_camera_modes(stdout: &str) -> Vec<CameraMode> {
|
||||
discovered.iter().any(|actual| {
|
||||
actual.width == supported.width
|
||||
&& actual.height == supported.height
|
||||
&& actual.fps >= supported.fps
|
||||
&& actual.fps == supported.fps
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
||||
@ -83,9 +83,12 @@ ioctl: VIDIOC_ENUM_FMT
|
||||
[0]: 'MJPG' (Motion-JPEG, compressed)
|
||||
Size: Discrete 1920x1080
|
||||
Interval: Discrete 0.033s (30.000 fps)
|
||||
Interval: Discrete 0.050s (20.000 fps)
|
||||
Interval: Discrete 0.067s (15.000 fps)
|
||||
Size: Discrete 1280x720
|
||||
Interval: Discrete 0.017s (60.000 fps)
|
||||
Interval: Discrete 0.033s (30.000 fps)
|
||||
Interval: Discrete 0.050s (20.000 fps)
|
||||
Size: Discrete 640x480
|
||||
Interval: Discrete 0.033s (30.000 fps)
|
||||
[1]: 'YUYV' (YUYV 4:2:2)
|
||||
@ -97,7 +100,41 @@ ioctl: VIDIOC_ENUM_FMT
|
||||
parse_supported_camera_modes(stdout),
|
||||
vec![
|
||||
CameraMode::new(1920, 1080, 30),
|
||||
CameraMode::new(1280, 720, 30)
|
||||
CameraMode::new(1920, 1080, 20),
|
||||
CameraMode::new(1280, 720, 30),
|
||||
CameraMode::new(1280, 720, 20)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_mode_parser_requires_exact_fps_for_supported_qualities() {
|
||||
let stdout = r#"
|
||||
ioctl: VIDIOC_ENUM_FMT
|
||||
Type: Video Capture
|
||||
|
||||
[0]: 'MJPG' (Motion-JPEG, compressed)
|
||||
Size: Discrete 1280x720
|
||||
Interval: Discrete 0.033s (30.000 fps)
|
||||
Size: Discrete 1920x1080
|
||||
Interval: Discrete 0.017s (60.000 fps)
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
parse_supported_camera_modes(stdout),
|
||||
vec![CameraMode::new(1280, 720, 30)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_camera_modes_match_the_core_logitech_profiles() {
|
||||
assert_eq!(
|
||||
lesavka_supported_camera_modes(),
|
||||
vec![
|
||||
CameraMode::new(1920, 1080, 30),
|
||||
CameraMode::new(1920, 1080, 20),
|
||||
CameraMode::new(1280, 720, 30),
|
||||
CameraMode::new(1280, 720, 20)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -275,7 +275,9 @@ fn camera_quality_tracks_selected_camera_supported_modes() {
|
||||
"cam-a".to_string(),
|
||||
vec![
|
||||
CameraMode::new(1920, 1080, 30),
|
||||
CameraMode::new(1920, 1080, 20),
|
||||
CameraMode::new(1280, 720, 30),
|
||||
CameraMode::new(1280, 720, 20),
|
||||
],
|
||||
),
|
||||
("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]),
|
||||
@ -292,7 +294,9 @@ fn camera_quality_tracks_selected_camera_supported_modes() {
|
||||
state.camera_quality_options(&catalog),
|
||||
vec![
|
||||
CameraMode::new(1920, 1080, 30),
|
||||
CameraMode::new(1280, 720, 30)
|
||||
CameraMode::new(1920, 1080, 20),
|
||||
CameraMode::new(1280, 720, 30),
|
||||
CameraMode::new(1280, 720, 20)
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@ -780,7 +780,9 @@ fn realistic_device_catalog() -> DeviceCatalog {
|
||||
"usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string(),
|
||||
vec![
|
||||
CameraMode::new(1920, 1080, 30),
|
||||
CameraMode::new(1920, 1080, 20),
|
||||
CameraMode::new(1280, 720, 30),
|
||||
CameraMode::new(1280, 720, 20),
|
||||
],
|
||||
)]
|
||||
.into_iter()
|
||||
|
||||
@ -13,7 +13,7 @@ use media_extract::{
|
||||
extract_audio_samples, extract_video_brightness, extract_video_colors, extract_video_timestamps,
|
||||
};
|
||||
use onset_detection::{
|
||||
DEFAULT_AUDIO_SAMPLE_RATE_HZ, correlate_coded_segments, correlate_segments,
|
||||
DEFAULT_AUDIO_SAMPLE_RATE_HZ, PulseSegment, correlate_coded_segments, correlate_segments,
|
||||
detect_audio_segments, detect_coded_audio_segments, detect_color_coded_video_segments,
|
||||
detect_video_segments,
|
||||
};
|
||||
@ -75,6 +75,9 @@ pub fn analyze_capture(
|
||||
)?
|
||||
};
|
||||
|
||||
let video_segments = filter_segments_to_analysis_window(video_segments, options, "video")?;
|
||||
let audio_segments = filter_segments_to_analysis_window(audio_segments, options, "audio")?;
|
||||
|
||||
if !coded_video_events {
|
||||
correlate_segments(
|
||||
&video_segments,
|
||||
@ -96,6 +99,45 @@ pub fn analyze_capture(
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_segments_to_analysis_window(
|
||||
segments: Vec<PulseSegment>,
|
||||
options: &SyncAnalysisOptions,
|
||||
stream_name: &str,
|
||||
) -> Result<Vec<PulseSegment>> {
|
||||
let start_s = options.analysis_start_s;
|
||||
let end_s = options.analysis_end_s;
|
||||
if start_s.is_none() && end_s.is_none() {
|
||||
return Ok(segments);
|
||||
}
|
||||
if let Some(start_s) = start_s
|
||||
&& (!start_s.is_finite() || start_s < 0.0)
|
||||
{
|
||||
bail!("{stream_name} analysis start must be a finite non-negative timestamp");
|
||||
}
|
||||
if let Some(end_s) = end_s
|
||||
&& (!end_s.is_finite() || end_s < 0.0)
|
||||
{
|
||||
bail!("{stream_name} analysis end must be a finite non-negative timestamp");
|
||||
}
|
||||
if let (Some(start_s), Some(end_s)) = (start_s, end_s)
|
||||
&& end_s <= start_s
|
||||
{
|
||||
bail!("{stream_name} analysis window end must be after its start");
|
||||
}
|
||||
|
||||
let filtered = segments
|
||||
.into_iter()
|
||||
.filter(|segment| {
|
||||
start_s.is_none_or(|start_s| segment.start_s >= start_s)
|
||||
&& end_s.is_none_or(|end_s| segment.start_s <= end_s)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if filtered.is_empty() {
|
||||
bail!("{stream_name} analysis window removed all detected sync pulses");
|
||||
}
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
fn reconcile_video_timestamps(timestamps: Vec<f64>, frame_count: usize) -> Result<Vec<f64>> {
|
||||
if frame_count == 0 {
|
||||
bail!("capture did not contain any decoded video brightness frames");
|
||||
|
||||
@ -304,6 +304,8 @@ pub struct SyncAnalysisOptions {
|
||||
pub pulse_width_s: f64,
|
||||
pub marker_tick_period: u32,
|
||||
pub event_width_codes: Vec<u32>,
|
||||
pub analysis_start_s: Option<f64>,
|
||||
pub analysis_end_s: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for SyncAnalysisOptions {
|
||||
@ -315,6 +317,8 @@ impl Default for SyncAnalysisOptions {
|
||||
pulse_width_s: DEFAULT_PULSE_WIDTH_S,
|
||||
marker_tick_period: DEFAULT_MARKER_TICK_PERIOD,
|
||||
event_width_codes: Vec::new(),
|
||||
analysis_start_s: None,
|
||||
analysis_end_s: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.19.14"
|
||||
version = "0.19.15"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -1011,6 +1011,10 @@ fi
|
||||
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"
|
||||
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}"
|
||||
printf 'LESAVKA_UVC_HEIGHT=%s\n' "${LESAVKA_UVC_HEIGHT:-720}"
|
||||
printf 'LESAVKA_UVC_FPS=%s\n' "${LESAVKA_UVC_FPS:-30}"
|
||||
printf 'LESAVKA_UVC_INTERVAL=%s\n' "${LESAVKA_UVC_INTERVAL:-333333}"
|
||||
printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}"
|
||||
printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}"
|
||||
printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}"
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
# the UVC modes the UI advertises. This is still a hardware-in-the-loop probe:
|
||||
# it captures the real Tethys UVC/UAC endpoints and summarizes sync,
|
||||
# freshness, and smoothness for each mode.
|
||||
#
|
||||
# Reconfigure mode is intentionally a fast runtime path: it updates the remote
|
||||
# Lesavka env files and cycles the UVC gadget, but it does not rebuild or
|
||||
# reinstall the server binary for each mode.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@ -25,13 +29,34 @@ LESAVKA_TLS_DOMAIN=${LESAVKA_TLS_DOMAIN:-lesavka-server}
|
||||
LESAVKA_SERVER_REPO=${LESAVKA_SERVER_REPO:-auto}
|
||||
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
||||
|
||||
LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-640x480@20,1280x720@30,1920x1080@20,1920x1080@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_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:-170000}
|
||||
LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-640x480@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}
|
||||
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}}
|
||||
LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-1280x720@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}
|
||||
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}
|
||||
LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX:-Lesavka|UGREEN|MACROSILICON|Composite|Capture}
|
||||
LESAVKA_SERVER_RC_MODE_SOURCE=${LESAVKA_SERVER_RC_MODE_SOURCE:-configured}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE_REF=${LESAVKA_SERVER_RC_RECONFIGURE_REF:-master}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE_COMMAND=${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND:-}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY:-runtime}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE_CODEC=${LESAVKA_SERVER_RC_RECONFIGURE_CODEC:-mjpeg}
|
||||
LESAVKA_SERVER_RC_ALLOW_GADGET_RESET=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET:-1}
|
||||
LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD=${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD:-1}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS=${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS:-4}
|
||||
LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE=${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE:-0}
|
||||
LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY:-1}
|
||||
LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD=${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD:-}
|
||||
LESAVKA_SERVER_RC_WAIT_TETHYS_READY=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY:-1}
|
||||
LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS:-60}
|
||||
LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS:-6}
|
||||
LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS:-3}
|
||||
LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1}
|
||||
LESAVKA_SERVER_RC_CONTINUE_ON_FAIL=${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL:-1}
|
||||
|
||||
LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}
|
||||
@ -80,10 +105,12 @@ parse_mode() {
|
||||
printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
|
||||
}
|
||||
|
||||
lookup_video_delay_us() {
|
||||
lookup_mode_delay_us() {
|
||||
local mode=$1
|
||||
local delay_map=$2
|
||||
local default_value=$3
|
||||
local entry key value
|
||||
IFS=',' read -r -a delay_entries <<<"${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
|
||||
IFS=',' read -r -a delay_entries <<<"${delay_map}"
|
||||
for entry in "${delay_entries[@]}"; do
|
||||
key=${entry%%=*}
|
||||
value=${entry#*=}
|
||||
@ -92,7 +119,212 @@ lookup_video_delay_us() {
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US}"
|
||||
printf '%s\n' "${default_value}"
|
||||
}
|
||||
|
||||
lookup_audio_delay_us() {
|
||||
lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}"
|
||||
}
|
||||
|
||||
lookup_video_delay_us() {
|
||||
lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US}"
|
||||
}
|
||||
|
||||
discover_local_webcam_modes() {
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
printf 'LESAVKA_SERVER_RC_MODES=auto requires python3 for local webcam mode discovery.\n' >&2
|
||||
exit 64
|
||||
fi
|
||||
if ! command -v v4l2-ctl >/dev/null 2>&1; then
|
||||
printf 'LESAVKA_SERVER_RC_MODES=auto requires v4l2-ctl for local webcam mode discovery.\n' >&2
|
||||
exit 64
|
||||
fi
|
||||
python3 - <<'PY' \
|
||||
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES}" \
|
||||
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS}" \
|
||||
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX}" \
|
||||
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX}"
|
||||
import glob
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sizes_raw, fps_raw, include_raw, exclude_raw = sys.argv[1:5]
|
||||
|
||||
def parse_sizes(raw):
|
||||
sizes = set()
|
||||
for entry in raw.split(","):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
match = re.fullmatch(r"(\d+)x(\d+)", entry)
|
||||
if not match:
|
||||
raise SystemExit(f"invalid discovery size {entry!r}; expected WIDTHxHEIGHT")
|
||||
sizes.add((int(match.group(1)), int(match.group(2))))
|
||||
return sizes
|
||||
|
||||
def parse_fps(raw):
|
||||
values = set()
|
||||
for entry in raw.split(","):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
if not entry.isdigit():
|
||||
raise SystemExit(f"invalid discovery fps {entry!r}; expected integer FPS")
|
||||
values.add(int(entry))
|
||||
return values
|
||||
|
||||
allowed_sizes = parse_sizes(sizes_raw)
|
||||
allowed_fps = parse_fps(fps_raw)
|
||||
include = re.compile(include_raw, re.IGNORECASE) if include_raw else None
|
||||
exclude = re.compile(exclude_raw, re.IGNORECASE) if exclude_raw else None
|
||||
all_modes = set()
|
||||
camera_modes = []
|
||||
|
||||
for dev in sorted(glob.glob("/dev/video*")):
|
||||
try:
|
||||
info = subprocess.run(
|
||||
["v4l2-ctl", "-d", dev, "--info"],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).stdout
|
||||
formats = subprocess.run(
|
||||
["v4l2-ctl", "-d", dev, "--list-formats-ext"],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).stdout
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
card = dev
|
||||
for line in info.splitlines():
|
||||
if "Card type" in line:
|
||||
card = line.split(":", 1)[1].strip()
|
||||
break
|
||||
label = f"{card} {dev}"
|
||||
if exclude and exclude.search(label):
|
||||
continue
|
||||
if include and not include.search(label):
|
||||
continue
|
||||
|
||||
current_size = None
|
||||
modes = set()
|
||||
for line in formats.splitlines():
|
||||
size_match = re.search(r"Size:\s+Discrete\s+(\d+)x(\d+)", line)
|
||||
if size_match:
|
||||
current_size = (int(size_match.group(1)), int(size_match.group(2)))
|
||||
continue
|
||||
fps_match = re.search(r"Interval:\s+Discrete\s+[^()]+\((\d+(?:\.\d+)?) fps\)", line)
|
||||
if not fps_match or current_size not in allowed_sizes:
|
||||
continue
|
||||
fps_float = float(fps_match.group(1))
|
||||
fps = int(round(fps_float))
|
||||
if abs(fps_float - fps) > 0.05 or fps not in allowed_fps:
|
||||
continue
|
||||
modes.add((current_size[0], current_size[1], fps))
|
||||
|
||||
if modes:
|
||||
all_modes.update(modes)
|
||||
camera_modes.append((label, modes))
|
||||
|
||||
if not all_modes:
|
||||
print(
|
||||
"no matching local webcam modes found; "
|
||||
f"sizes={sizes_raw} fps={fps_raw} include={include_raw!r} exclude={exclude_raw!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(64)
|
||||
|
||||
for label, modes in camera_modes:
|
||||
rendered = ",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(modes))
|
||||
print(f" -> local webcam {label}: {rendered}", file=sys.stderr)
|
||||
|
||||
print(",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(all_modes)), end="")
|
||||
PY
|
||||
}
|
||||
|
||||
clear_remote_sudo_password() {
|
||||
LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD=""
|
||||
}
|
||||
trap clear_remote_sudo_password EXIT
|
||||
|
||||
ensure_remote_sudo_password() {
|
||||
[[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0
|
||||
[[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0
|
||||
[[ -n "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}" ]] && return 0
|
||||
|
||||
if [[ "${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY}" == "0" ]]; then
|
||||
printf 'remote sudo password is required for automatic reconfigure; set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD or leave LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=1.\n' >&2
|
||||
exit 64
|
||||
fi
|
||||
if [[ ! -t 0 ]]; then
|
||||
printf 'remote sudo password is required, but stdin is not a terminal.\n' >&2
|
||||
printf 'Re-run from a terminal or set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD in the environment.\n' >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
printf 'Theia sudo password for %s: ' "${LESAVKA_SERVER_HOST}" >&2
|
||||
IFS= read -r -s LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD
|
||||
printf '\n' >&2
|
||||
}
|
||||
|
||||
run_remote_root_script() {
|
||||
local description=$1
|
||||
shift
|
||||
local script_file remote_wrapper ssh_cmd status
|
||||
script_file="$(mktemp)"
|
||||
cat >"${script_file}"
|
||||
|
||||
remote_wrapper='set -euo pipefail
|
||||
read -r __lesavka_sudo_password
|
||||
__lesavka_script=$(mktemp)
|
||||
cleanup() { rm -f "$__lesavka_script"; }
|
||||
trap cleanup EXIT
|
||||
cat >"$__lesavka_script"
|
||||
printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" -v
|
||||
printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" bash "$__lesavka_script" "$@"
|
||||
'
|
||||
printf -v ssh_cmd 'bash -c %q _' "${remote_wrapper}"
|
||||
for arg in "$@"; do
|
||||
printf -v ssh_cmd '%s %q' "${ssh_cmd}" "${arg}"
|
||||
done
|
||||
|
||||
set +e
|
||||
{
|
||||
printf '%s\n' "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}"
|
||||
cat "${script_file}"
|
||||
} | ssh ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" "${ssh_cmd}"
|
||||
status=$?
|
||||
set -e
|
||||
rm -f "${script_file}"
|
||||
if [[ "${status}" -ne 0 ]]; then
|
||||
printf '%s failed on %s with exit %s\n' "${description}" "${LESAVKA_SERVER_HOST}" "${status}" >&2
|
||||
fi
|
||||
return "${status}"
|
||||
}
|
||||
|
||||
prime_remote_sudo() {
|
||||
[[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0
|
||||
[[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0
|
||||
ensure_remote_sudo_password
|
||||
echo "==> priming remote sudo on ${LESAVKA_SERVER_HOST}"
|
||||
run_remote_root_script "remote sudo prime" <<'REMOTE_SUDO_PRIME'
|
||||
set -euo pipefail
|
||||
true
|
||||
REMOTE_SUDO_PRIME
|
||||
}
|
||||
|
||||
prebuild_probe_tools() {
|
||||
[[ "${LESAVKA_SERVER_RC_PROBE_PREBUILD}" != "0" ]] || return 0
|
||||
echo "==> prebuilding relay control/analyzer once for the mode matrix"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
cargo build -p lesavka_client --bin lesavka-sync-analyze --bin lesavka-relayctl
|
||||
)
|
||||
}
|
||||
|
||||
reconfigure_server_mode() {
|
||||
@ -112,71 +344,281 @@ reconfigure_server_mode() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
local prepare_output repo interval remote_cmd
|
||||
prepare_output="$(
|
||||
ssh ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" bash -s -- \
|
||||
"${LESAVKA_SERVER_REPO}" \
|
||||
"${LESAVKA_SERVER_RC_RECONFIGURE_REF}" \
|
||||
"${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE}" <<'REMOTE_PREPARE'
|
||||
set -euo pipefail
|
||||
repo=$1
|
||||
ref=$2
|
||||
update_checkout=$3
|
||||
if [[ "${repo}" == "auto" ]]; then
|
||||
for candidate in \
|
||||
"${HOME}/Development/lesavka" \
|
||||
/home/theia/Development/lesavka \
|
||||
/home/brad/Development/lesavka \
|
||||
"${HOME}/lesavka" \
|
||||
/opt/lesavka \
|
||||
/srv/lesavka \
|
||||
/tmp/lesavka-server-rc-matrix-source
|
||||
do
|
||||
if [[ -d "${candidate}/.git" && -f "${candidate}/scripts/install/server.sh" ]]; then
|
||||
repo="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ "${repo}" == "auto" || ! -f "${repo}/scripts/install/server.sh" ]]; then
|
||||
printf 'could not find a Lesavka checkout on this server host.\n' >&2
|
||||
printf 'Set LESAVKA_SERVER_REPO=/path/to/lesavka, or create one of the standard checkout paths.\n' >&2
|
||||
exit 65
|
||||
fi
|
||||
printf ' ↪ using server repo: %s\n' "${repo}"
|
||||
cd "${repo}"
|
||||
if [[ "${update_checkout}" != "0" ]]; then
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
printf 'server checkout has local changes; refusing to update it for the mode matrix.\n' >&2
|
||||
printf 'Run with LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=0, or clean/update the checkout intentionally.\n' >&2
|
||||
exit 66
|
||||
fi
|
||||
git fetch --all --prune
|
||||
git checkout "${ref}"
|
||||
git pull --ff-only
|
||||
else
|
||||
printf ' ↪ leaving server checkout untouched; set LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=1 to update it first\n'
|
||||
fi
|
||||
printf '__LESAVKA_SERVER_REPO__=%s\n' "${repo}"
|
||||
REMOTE_PREPARE
|
||||
)"
|
||||
printf '%s\n' "${prepare_output}" | sed '/^__LESAVKA_SERVER_REPO__=/d'
|
||||
repo="$(awk -F= '/^__LESAVKA_SERVER_REPO__=/{print $2; exit}' <<<"${prepare_output}")"
|
||||
if [[ -z "${repo}" ]]; then
|
||||
printf 'could not determine the prepared server repo path on %s\n' "${LESAVKA_SERVER_HOST}" >&2
|
||||
return 65
|
||||
if [[ "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" != "runtime" ]]; then
|
||||
printf 'unsupported LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=%s\n' "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" >&2
|
||||
printf 'Use runtime for fast mode changes, or LESAVKA_SERVER_RC_RECONFIGURE_COMMAND for a custom install/reconfigure command.\n' >&2
|
||||
return 64
|
||||
fi
|
||||
|
||||
local interval
|
||||
interval=$((10000000 / fps))
|
||||
printf -v remote_cmd \
|
||||
'cd %q && sudo env LESAVKA_REF=%q LESAVKA_INSTALL_UVC_CODEC=mjpeg LESAVKA_UVC_WIDTH=%q LESAVKA_UVC_HEIGHT=%q LESAVKA_UVC_FPS=%q LESAVKA_UVC_INTERVAL=%q ./scripts/install/server.sh' \
|
||||
"${repo}" \
|
||||
"${LESAVKA_SERVER_RC_RECONFIGURE_REF}" \
|
||||
run_remote_root_script "runtime UVC reconfigure for ${mode}" \
|
||||
"${mode}" \
|
||||
"${width}" \
|
||||
"${height}" \
|
||||
"${fps}" \
|
||||
"${interval}"
|
||||
ssh -tt ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" "${remote_cmd}"
|
||||
"${interval}" \
|
||||
"${LESAVKA_SERVER_RC_RECONFIGURE_CODEC}" \
|
||||
"${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" \
|
||||
"${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD}" \
|
||||
"${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS}" \
|
||||
"${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE}" <<'REMOTE_RECONFIGURE'
|
||||
set -euo pipefail
|
||||
mode=$1
|
||||
width=$2
|
||||
height=$3
|
||||
fps=$4
|
||||
interval=$5
|
||||
codec=$6
|
||||
allow_gadget_reset=$7
|
||||
force_gadget_rebuild=$8
|
||||
settle_seconds=$9
|
||||
verbose=${10}
|
||||
|
||||
set_env_value() {
|
||||
local file=$1
|
||||
local key=$2
|
||||
local value=$3
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
if [[ -f "${file}" ]]; then
|
||||
awk -v key="${key}" -v value="${value}" '
|
||||
BEGIN { wrote = 0 }
|
||||
$0 ~ "^" key "=" {
|
||||
print key "=" value
|
||||
wrote = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!wrote) {
|
||||
print key "=" value
|
||||
}
|
||||
}
|
||||
' "${file}" >"${tmp}"
|
||||
else
|
||||
printf '%s=%s\n' "${key}" "${value}" >"${tmp}"
|
||||
fi
|
||||
install -m 0644 "${tmp}" "${file}"
|
||||
rm -f "${tmp}"
|
||||
}
|
||||
|
||||
if [[ ! -x /usr/local/bin/lesavka-core.sh ]]; then
|
||||
printf 'missing /usr/local/bin/lesavka-core.sh; run the server installer once before using fast runtime reconfigure.\n' >&2
|
||||
exit 65
|
||||
fi
|
||||
if [[ ! -x /usr/local/bin/lesavka-server || ! -x /usr/local/bin/lesavka-uvc ]]; then
|
||||
printf 'missing installed Lesavka binaries; run the server installer once before using fast runtime reconfigure.\n' >&2
|
||||
exit 65
|
||||
fi
|
||||
|
||||
install -d -m 0755 /etc/lesavka
|
||||
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_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}"
|
||||
set_env_value /etc/lesavka/server.env LESAVKA_UVC_FPS "${fps}"
|
||||
set_env_value /etc/lesavka/server.env LESAVKA_UVC_INTERVAL "${interval}"
|
||||
|
||||
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_CODEC "${codec}"
|
||||
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_WIDTH "${width}"
|
||||
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_HEIGHT "${height}"
|
||||
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_FPS "${fps}"
|
||||
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_INTERVAL "${interval}"
|
||||
|
||||
printf ' ↪ fast runtime env updated: CAM_OUTPUT=uvc UVC_MODE=%s codec=%s\n' "${mode}" "${codec}"
|
||||
systemctl daemon-reload
|
||||
systemctl stop lesavka-server >/dev/null 2>&1 || true
|
||||
systemctl stop lesavka-uvc >/dev/null 2>&1 || true
|
||||
systemctl reset-failed lesavka-core lesavka-uvc lesavka-server >/dev/null 2>&1 || true
|
||||
|
||||
if [[ "${allow_gadget_reset}" != "0" && "${force_gadget_rebuild}" != "0" ]]; then
|
||||
printf ' ↪ cycling UVC gadget descriptors for %s\n' "${mode}"
|
||||
mode_log_id=$(printf '%s' "${mode}" | tr -c '[:alnum:]_.-' '_')
|
||||
core_log="/tmp/lesavka-core-reconfigure-${mode_log_id}.log"
|
||||
core_cmd=(
|
||||
env
|
||||
LESAVKA_ALLOW_GADGET_RESET=1 \
|
||||
LESAVKA_FORCE_GADGET_REBUILD=1 \
|
||||
LESAVKA_ATTACH_WRITE_UDC=1 \
|
||||
LESAVKA_DETACH_CLEAR_UDC=1 \
|
||||
LESAVKA_UVC_FALLBACK=0 \
|
||||
LESAVKA_UVC_CODEC="${codec}" \
|
||||
LESAVKA_UVC_WIDTH="${width}" \
|
||||
LESAVKA_UVC_HEIGHT="${height}" \
|
||||
LESAVKA_UVC_FPS="${fps}" \
|
||||
LESAVKA_UVC_INTERVAL="${interval}" \
|
||||
/usr/local/bin/lesavka-core.sh
|
||||
)
|
||||
if [[ "${verbose}" != "0" ]]; then
|
||||
"${core_cmd[@]}"
|
||||
else
|
||||
if "${core_cmd[@]}" >"${core_log}" 2>&1; then
|
||||
printf ' ↪ lesavka-core reconfigure log: %s\n' "${core_log}"
|
||||
else
|
||||
rc=$?
|
||||
printf 'lesavka-core reconfigure failed; tail of %s follows\n' "${core_log}" >&2
|
||||
tail -n 80 "${core_log}" >&2 || true
|
||||
exit "${rc}"
|
||||
fi
|
||||
fi
|
||||
elif [[ "${allow_gadget_reset}" != "0" ]]; then
|
||||
printf ' ↪ gadget reset allowed, but force rebuild disabled; refreshing services only\n'
|
||||
else
|
||||
printf ' ↪ preserving attached UVC gadget; descriptors may remain on the previous mode\n'
|
||||
fi
|
||||
|
||||
systemctl start lesavka-uvc
|
||||
systemctl restart lesavka-server
|
||||
sleep "${settle_seconds}"
|
||||
systemctl is-active lesavka-core lesavka-uvc lesavka-server >/dev/null
|
||||
printf ' ↪ services active after %s reconfigure\n' "${mode}"
|
||||
REMOTE_RECONFIGURE
|
||||
}
|
||||
|
||||
wait_tethys_media_ready() {
|
||||
local mode=$1
|
||||
local width=$2
|
||||
local height=$3
|
||||
local fps=$4
|
||||
[[ "${LESAVKA_SERVER_RC_WAIT_TETHYS_READY}" != "0" ]] || return 0
|
||||
|
||||
echo "==> waiting for Tethys media endpoints for ${mode}"
|
||||
ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
|
||||
"${mode}" \
|
||||
"${width}" \
|
||||
"${height}" \
|
||||
"${fps}" \
|
||||
"${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}" \
|
||||
"${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}" \
|
||||
"${REMOTE_CAPTURE_STACK}" \
|
||||
"${REMOTE_AUDIO_SOURCE}" <<'REMOTE_TETHYS_READY'
|
||||
set -euo pipefail
|
||||
mode=$1
|
||||
width=$2
|
||||
height=$3
|
||||
fps=$4
|
||||
timeout_seconds=$5
|
||||
settle_seconds=$6
|
||||
capture_stack=$7
|
||||
audio_source=$8
|
||||
|
||||
sleep "${settle_seconds}"
|
||||
|
||||
find_lesavka_video_device() {
|
||||
if [[ -d /dev/v4l/by-id ]]; then
|
||||
while IFS= read -r path; do
|
||||
[[ -e "${path}" ]] || continue
|
||||
printf '%s\n' "${path}"
|
||||
return 0
|
||||
done < <(find /dev/v4l/by-id -maxdepth 1 -type l -name '*Lesavka*video-index0' 2>/dev/null | sort)
|
||||
fi
|
||||
|
||||
if command -v v4l2-ctl >/dev/null 2>&1; then
|
||||
v4l2-ctl --list-devices 2>/dev/null \
|
||||
| awk '
|
||||
BEGIN { want=0 }
|
||||
/Lesavka Composite|Lesavka.*UVC/ { want=1; next }
|
||||
/^[^ \t]/ { want=0 }
|
||||
want && /^[ \t]+\/dev\/video[0-9]+/ {
|
||||
gsub(/^[ \t]+/, "", $0)
|
||||
print
|
||||
exit
|
||||
}
|
||||
'
|
||||
fi
|
||||
}
|
||||
|
||||
video_ready() {
|
||||
local dev=$1
|
||||
[[ -n "${dev}" ]] || return 1
|
||||
[[ -e "${dev}" ]] || return 1
|
||||
if ! command -v v4l2-ctl >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local listing
|
||||
listing="$(v4l2-ctl -d "${dev}" --list-formats-ext 2>/dev/null || true)"
|
||||
grep -q "Size: Discrete ${width}x${height}" <<<"${listing}" || return 1
|
||||
grep -Eq "(^|[^0-9])${fps}(\\.0+)? fps" <<<"${listing}" || return 1
|
||||
}
|
||||
|
||||
pulse_ready() {
|
||||
if ! command -v pactl >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
if [[ "${audio_source}" == pulse:* ]]; then
|
||||
local requested=${audio_source#pulse:}
|
||||
pactl list short sources 2>/dev/null | awk -v requested="${requested}" '$2 == requested { found=1 } END { exit found ? 0 : 1 }'
|
||||
return
|
||||
fi
|
||||
pactl list short sources 2>/dev/null | grep -Eq 'alsa_input\..*Lesavka_Composite|Lesavka_Composite'
|
||||
}
|
||||
|
||||
alsa_ready() {
|
||||
if [[ "${audio_source}" == alsa:* ]]; then
|
||||
[[ -e "${audio_source#alsa:}" ]] || return 1
|
||||
return 0
|
||||
fi
|
||||
command -v arecord >/dev/null 2>&1 || return 1
|
||||
arecord -l 2>/dev/null | grep -Eq 'Lesavka|UAC2_Gadget|UAC2Gadget|Composite'
|
||||
}
|
||||
|
||||
audio_ready() {
|
||||
case "${capture_stack}" in
|
||||
pulse)
|
||||
pulse_ready
|
||||
;;
|
||||
alsa)
|
||||
alsa_ready
|
||||
;;
|
||||
auto)
|
||||
pulse_ready || alsa_ready
|
||||
;;
|
||||
pwpipe)
|
||||
command -v pw-dump >/dev/null 2>&1 && pw-dump 2>/dev/null | grep -Eq 'Lesavka_Composite|Lesavka Composite'
|
||||
;;
|
||||
*)
|
||||
pulse_ready || alsa_ready
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
deadline=$((SECONDS + timeout_seconds))
|
||||
last_video='none'
|
||||
last_audio='none'
|
||||
while (( SECONDS <= deadline )); do
|
||||
video_dev="$(find_lesavka_video_device || true)"
|
||||
last_video="${video_dev:-none}"
|
||||
if [[ -n "${video_dev}" ]] && video_ready "${video_dev}"; then
|
||||
if audio_ready; then
|
||||
printf ' ↪ Tethys media ready: video=%s mode=%s audio_stack=%s\n' "${video_dev}" "${mode}" "${capture_stack}"
|
||||
exit 0
|
||||
fi
|
||||
last_audio='not-ready'
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
printf 'timed out waiting for Tethys Lesavka media endpoints for %s after %ss\n' "${mode}" "${timeout_seconds}" >&2
|
||||
printf 'last video candidate: %s\n' "${last_video}" >&2
|
||||
printf 'last audio candidate: %s\n' "${last_audio}" >&2
|
||||
if command -v v4l2-ctl >/dev/null 2>&1; then
|
||||
v4l2-ctl --list-devices >&2 || true
|
||||
if [[ "${last_video}" != "none" ]]; then
|
||||
v4l2-ctl -d "${last_video}" --list-formats-ext >&2 || true
|
||||
fi
|
||||
fi
|
||||
if command -v pactl >/dev/null 2>&1; then
|
||||
pactl list short sources | grep -iE 'lesavka|uac|composite' >&2 || true
|
||||
fi
|
||||
if command -v arecord >/dev/null 2>&1; then
|
||||
arecord -l >&2 || true
|
||||
fi
|
||||
exit 70
|
||||
REMOTE_TETHYS_READY
|
||||
}
|
||||
|
||||
write_mode_result() {
|
||||
@ -185,10 +627,11 @@ write_mode_result() {
|
||||
local height=$3
|
||||
local fps=$4
|
||||
local video_delay_us=$5
|
||||
local run_status=$6
|
||||
local run_log=$7
|
||||
local artifact_dir=$8
|
||||
local output_json=$9
|
||||
local audio_delay_us=$6
|
||||
local run_status=$7
|
||||
local run_log=$8
|
||||
local artifact_dir=$9
|
||||
local output_json=${10}
|
||||
|
||||
python3 - <<'PY' \
|
||||
"${mode}" \
|
||||
@ -196,6 +639,7 @@ write_mode_result() {
|
||||
"${height}" \
|
||||
"${fps}" \
|
||||
"${video_delay_us}" \
|
||||
"${audio_delay_us}" \
|
||||
"${run_status}" \
|
||||
"${run_log}" \
|
||||
"${artifact_dir}" \
|
||||
@ -221,6 +665,7 @@ import sys
|
||||
height_raw,
|
||||
fps_raw,
|
||||
video_delay_raw,
|
||||
audio_delay_raw,
|
||||
run_status_raw,
|
||||
run_log,
|
||||
artifact_dir_raw,
|
||||
@ -276,6 +721,7 @@ def nested(mapping, *keys, default=None):
|
||||
artifact_dir = pathlib.Path(artifact_dir_raw) if artifact_dir_raw else pathlib.Path()
|
||||
report = load_json(artifact_dir / "report.json")
|
||||
correlation = load_json(artifact_dir / "output-delay-correlation.json")
|
||||
calibration = load_json(artifact_dir / "output-delay-calibration.json")
|
||||
freshness = correlation.get("freshness") or {}
|
||||
smoothness = correlation.get("smoothness") or {}
|
||||
video = smoothness.get("video") or {}
|
||||
@ -328,13 +774,14 @@ artifact = {
|
||||
"width": as_int(width_raw),
|
||||
"height": as_int(height_raw),
|
||||
"fps": as_int(fps_raw),
|
||||
"audio_delay_us": 0,
|
||||
"audio_delay_us": as_int(audio_delay_raw),
|
||||
"video_delay_us": as_int(video_delay_raw),
|
||||
"run_status": run_status,
|
||||
"run_log": run_log,
|
||||
"artifact_dir": str(artifact_dir),
|
||||
"report_json": str(artifact_dir / "report.json"),
|
||||
"correlation_json": str(artifact_dir / "output-delay-correlation.json"),
|
||||
"calibration_json": str(artifact_dir / "output-delay-calibration.json"),
|
||||
"passed": not reasons,
|
||||
"failure_reasons": reasons,
|
||||
"sync": {
|
||||
@ -371,6 +818,21 @@ artifact = {
|
||||
"audio_low_rms_windows": low_rms,
|
||||
"audio_median_rms": as_float(nested(audio_rms, "rms_stats", "median_rms"), 0.0),
|
||||
},
|
||||
"output_delay_calibration": {
|
||||
"ready": calibration.get("ready") is True,
|
||||
"decision": calibration.get("decision", "unknown"),
|
||||
"target": calibration.get("target", ""),
|
||||
"paired_event_count": as_int(calibration.get("paired_event_count"), 0),
|
||||
"measured_device_skew_ms": as_float(calibration.get("measured_device_skew_ms"), 0.0),
|
||||
"p95_abs_skew_ms": as_float(calibration.get("p95_abs_skew_ms"), 0.0),
|
||||
"max_abs_skew_ms": as_float(calibration.get("max_abs_skew_ms"), 0.0),
|
||||
"drift_ms": as_float(calibration.get("drift_ms"), 0.0),
|
||||
"audio_offset_adjust_us": as_int(calibration.get("audio_offset_adjust_us"), 0),
|
||||
"video_offset_adjust_us": as_int(calibration.get("video_offset_adjust_us"), 0),
|
||||
"audio_target_offset_us": as_int(calibration.get("audio_target_offset_us"), 0),
|
||||
"video_target_offset_us": as_int(calibration.get("video_target_offset_us"), 0),
|
||||
"note": calibration.get("note", ""),
|
||||
},
|
||||
}
|
||||
pathlib.Path(output_json).write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n")
|
||||
PY
|
||||
@ -407,6 +869,7 @@ fieldnames = [
|
||||
"mode",
|
||||
"passed",
|
||||
"video_delay_us",
|
||||
"audio_delay_us",
|
||||
"sync_status",
|
||||
"p95_abs_skew_ms",
|
||||
"median_skew_ms",
|
||||
@ -419,6 +882,12 @@ fieldnames = [
|
||||
"video_undecodable",
|
||||
"audio_hiccups",
|
||||
"audio_low_rms",
|
||||
"calibration_ready",
|
||||
"calibration_decision",
|
||||
"calibration_target",
|
||||
"calibration_measured_skew_ms",
|
||||
"calibration_video_target_offset_us",
|
||||
"calibration_audio_target_offset_us",
|
||||
"artifact_dir",
|
||||
]
|
||||
with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||
@ -429,6 +898,7 @@ with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||
"mode": result.get("mode"),
|
||||
"passed": result.get("passed"),
|
||||
"video_delay_us": result.get("video_delay_us"),
|
||||
"audio_delay_us": result.get("audio_delay_us"),
|
||||
"sync_status": (result.get("sync") or {}).get("status"),
|
||||
"p95_abs_skew_ms": (result.get("sync") or {}).get("p95_abs_skew_ms"),
|
||||
"median_skew_ms": (result.get("sync") or {}).get("median_skew_ms"),
|
||||
@ -441,6 +911,12 @@ with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||
"video_undecodable": (result.get("smoothness") or {}).get("video_undecodable_frames"),
|
||||
"audio_hiccups": (result.get("smoothness") or {}).get("audio_hiccups"),
|
||||
"audio_low_rms": (result.get("smoothness") or {}).get("audio_low_rms_windows"),
|
||||
"calibration_ready": (result.get("output_delay_calibration") or {}).get("ready"),
|
||||
"calibration_decision": (result.get("output_delay_calibration") or {}).get("decision"),
|
||||
"calibration_target": (result.get("output_delay_calibration") or {}).get("target"),
|
||||
"calibration_measured_skew_ms": (result.get("output_delay_calibration") or {}).get("measured_device_skew_ms"),
|
||||
"calibration_video_target_offset_us": (result.get("output_delay_calibration") or {}).get("video_target_offset_us"),
|
||||
"calibration_audio_target_offset_us": (result.get("output_delay_calibration") or {}).get("audio_target_offset_us"),
|
||||
"artifact_dir": result.get("artifact_dir"),
|
||||
})
|
||||
|
||||
@ -453,13 +929,22 @@ for result in results:
|
||||
sync = result.get("sync") or {}
|
||||
freshness = result.get("freshness") or {}
|
||||
smooth = result.get("smoothness") or {}
|
||||
calibration = result.get("output_delay_calibration") or {}
|
||||
marker = "PASS" if result.get("passed") else "FAIL"
|
||||
lines.append(
|
||||
f"- {marker} {result.get('mode')}: "
|
||||
f"delays video={result.get('video_delay_us', 0)}us audio={result.get('audio_delay_us', 0)}us; "
|
||||
f"sync {sync.get('status')} p95={sync.get('p95_abs_skew_ms', 0.0):.1f}ms median={sync.get('median_skew_ms', 0.0):+.1f}ms; "
|
||||
f"freshness {freshness.get('status')} budget={freshness.get('worst_event_age_with_uncertainty_ms') or 0.0:.1f}ms; "
|
||||
f"smooth video hiccups={smooth.get('video_hiccups', 0)} missing={smooth.get('video_estimated_missing_frames', 0)} undecodable={smooth.get('video_undecodable_frames', 0)} audio hiccups={smooth.get('audio_hiccups', 0)}"
|
||||
)
|
||||
lines.append(
|
||||
" calibration: "
|
||||
f"{calibration.get('decision', 'unknown')} ready={calibration.get('ready', False)} "
|
||||
f"target={calibration.get('target', '')} measured={calibration.get('measured_device_skew_ms', 0.0):+.1f}ms "
|
||||
f"video_target={calibration.get('video_target_offset_us', 0)}us "
|
||||
f"audio_target={calibration.get('audio_target_offset_us', 0)}us"
|
||||
)
|
||||
for reason in result.get("failure_reasons") or []:
|
||||
lines.append(f" reason: {reason}")
|
||||
summary_txt.write_text("\n".join(lines) + "\n")
|
||||
@ -467,26 +952,41 @@ print("\n".join(lines))
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ "${LESAVKA_SERVER_RC_MODES}" == "auto" ]]; then
|
||||
echo "==> discovering local webcam-backed Lesavka modes"
|
||||
LESAVKA_SERVER_RC_MODES="$(discover_local_webcam_modes)"
|
||||
LESAVKA_SERVER_RC_MODE_SOURCE=local-v4l2
|
||||
fi
|
||||
|
||||
echo "==> server-to-RC mode matrix"
|
||||
echo " ↪ modes=${LESAVKA_SERVER_RC_MODES}"
|
||||
echo " ↪ delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
|
||||
echo " ↪ mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}"
|
||||
echo " ↪ video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
|
||||
echo " ↪ audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}"
|
||||
echo " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}"
|
||||
echo " ↪ reconfigure=${LESAVKA_SERVER_RC_RECONFIGURE} strategy=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY} allow_gadget_reset=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}"
|
||||
echo " ↪ tethys_ready=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY} settle=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}s timeout=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}s preroll_discard=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}s"
|
||||
echo " ↪ artifact_dir=${MATRIX_REPORT_DIR}"
|
||||
|
||||
prime_remote_sudo
|
||||
prebuild_probe_tools
|
||||
|
||||
IFS=',' read -r -a modes <<<"${LESAVKA_SERVER_RC_MODES}"
|
||||
for mode in "${modes[@]}"; do
|
||||
mode="${mode//[[:space:]]/}"
|
||||
[[ -n "${mode}" ]] || continue
|
||||
read -r width height fps < <(parse_mode "${mode}")
|
||||
video_delay_us="$(lookup_video_delay_us "${mode}")"
|
||||
audio_delay_us="$(lookup_audio_delay_us "${mode}")"
|
||||
id="$(mode_id "${mode}")"
|
||||
mode_dir="${MATRIX_REPORT_DIR}/${id}"
|
||||
mode_log="${mode_dir}/mode-run.log"
|
||||
mode_result="${mode_dir}/mode-result.json"
|
||||
mkdir -p "${mode_dir}"
|
||||
|
||||
echo "==> mode ${mode}: video_delay_us=${video_delay_us}"
|
||||
echo "==> mode ${mode}: video_delay_us=${video_delay_us} audio_delay_us=${audio_delay_us}"
|
||||
reconfigure_server_mode "${mode}" "${width}" "${height}" "${fps}"
|
||||
wait_tethys_media_ready "${mode}" "${width}" "${height}" "${fps}"
|
||||
|
||||
set +e
|
||||
TETHYS_HOST="${TETHYS_HOST}" \
|
||||
@ -500,13 +1000,15 @@ for mode in "${modes[@]}"; do
|
||||
REMOTE_PULSE_VIDEO_MODE="${REMOTE_PULSE_VIDEO_MODE}" \
|
||||
REMOTE_CAPTURE_STACK="${REMOTE_CAPTURE_STACK}" \
|
||||
REMOTE_AUDIO_SOURCE="${REMOTE_AUDIO_SOURCE}" \
|
||||
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS="${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}" \
|
||||
PROBE_PREBUILD=0 \
|
||||
VIDEO_SIZE="${width}x${height}" \
|
||||
VIDEO_FPS="${fps}" \
|
||||
VIDEO_FORMAT=mjpeg \
|
||||
REMOTE_EXPECT_UVC_WIDTH="${width}" \
|
||||
REMOTE_EXPECT_UVC_HEIGHT="${height}" \
|
||||
REMOTE_EXPECT_UVC_FPS="${fps}" \
|
||||
LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US="${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US}" \
|
||||
LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US="${audio_delay_us}" \
|
||||
LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US="${video_delay_us}" \
|
||||
LESAVKA_OUTPUT_DELAY_APPLY=0 \
|
||||
LESAVKA_OUTPUT_DELAY_SAVE=0 \
|
||||
@ -526,7 +1028,7 @@ for mode in "${modes[@]}"; do
|
||||
if [[ -z "${artifact_dir}" ]]; then
|
||||
artifact_dir="$(find "${mode_dir}" -mindepth 1 -maxdepth 1 -type d -name 'lesavka-output-delay-probe-*' | sort | tail -n1 || true)"
|
||||
fi
|
||||
write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${video_delay_us}" "${run_status}" "${mode_log}" "${artifact_dir}" "${mode_result}"
|
||||
write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${video_delay_us}" "${audio_delay_us}" "${run_status}" "${mode_log}" "${artifact_dir}" "${mode_result}"
|
||||
|
||||
if [[ "${run_status}" -ne 0 && "${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL}" == "0" ]]; then
|
||||
break
|
||||
|
||||
@ -41,8 +41,11 @@ REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
|
||||
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-copy}
|
||||
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
|
||||
REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto}
|
||||
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}
|
||||
ANALYSIS_NORMALIZE=${ANALYSIS_NORMALIZE:-0}
|
||||
ANALYSIS_SCALE_WIDTH=${ANALYSIS_SCALE_WIDTH:-1280}
|
||||
ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-1}
|
||||
ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS=${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS:-1.0}
|
||||
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
||||
PROBE_PREBUILD=${PROBE_PREBUILD:-1}
|
||||
ANALYZE_BIN=${ANALYZE_BIN:-"${REPO_ROOT}/target/debug/lesavka-sync-analyze"}
|
||||
@ -553,35 +556,40 @@ server_uvc_fps=$(read_env_value "LESAVKA_UVC_FPS" /etc/lesavka/server.env)
|
||||
runtime_uvc_width=$(read_env_value "LESAVKA_UVC_WIDTH" /etc/lesavka/uvc.env)
|
||||
runtime_uvc_height=$(read_env_value "LESAVKA_UVC_HEIGHT" /etc/lesavka/uvc.env)
|
||||
runtime_uvc_fps=$(read_env_value "LESAVKA_UVC_FPS" /etc/lesavka/uvc.env)
|
||||
effective_uvc_codec=${server_uvc_codec:-$runtime_uvc_codec}
|
||||
effective_uvc_width=${server_uvc_width:-$runtime_uvc_width}
|
||||
effective_uvc_height=${server_uvc_height:-$runtime_uvc_height}
|
||||
effective_uvc_fps=${server_uvc_fps:-$runtime_uvc_fps}
|
||||
|
||||
printf ' ↪ server.env CAM_OUTPUT=%s\n' "${cam_output:-<unset>}"
|
||||
printf ' ↪ server.env UVC_CODEC=%s\n' "${server_uvc_codec:-<unset>}"
|
||||
printf ' ↪ uvc.env UVC_CODEC=%s\n' "${runtime_uvc_codec:-<unset>}"
|
||||
printf ' ↪ server.env UVC_MODE=%sx%s@%s\n' "${server_uvc_width:-<unset>}" "${server_uvc_height:-<unset>}" "${server_uvc_fps:-<unset>}"
|
||||
printf ' ↪ uvc.env UVC_MODE=%sx%s@%s\n' "${runtime_uvc_width:-<unset>}" "${runtime_uvc_height:-<unset>}" "${runtime_uvc_fps:-<unset>}"
|
||||
printf ' ↪ effective UVC_MODE=%sx%s@%s\n' "${effective_uvc_width:-<unset>}" "${effective_uvc_height:-<unset>}" "${effective_uvc_fps:-<unset>}"
|
||||
|
||||
if [[ -n "${expect_cam_output}" && "${cam_output}" != "${expect_cam_output}" ]]; then
|
||||
printf 'expected CAM_OUTPUT=%s but found %s\n' "${expect_cam_output}" "${cam_output:-<unset>}" >&2
|
||||
exit 64
|
||||
fi
|
||||
if [[ -n "${expect_uvc_codec}" && "${server_uvc_codec}" != "${expect_uvc_codec}" ]]; then
|
||||
printf 'expected server.env UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${server_uvc_codec:-<unset>}" >&2
|
||||
if [[ -n "${expect_uvc_codec}" && "${effective_uvc_codec}" != "${expect_uvc_codec}" ]]; then
|
||||
printf 'expected effective UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${effective_uvc_codec:-<unset>}" >&2
|
||||
exit 65
|
||||
fi
|
||||
if [[ -n "${expect_uvc_codec}" && "${runtime_uvc_codec}" != "${expect_uvc_codec}" ]]; then
|
||||
printf 'expected uvc.env UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${runtime_uvc_codec:-<unset>}" >&2
|
||||
exit 66
|
||||
fi
|
||||
if [[ -n "${expect_uvc_width}" && "${server_uvc_width}" != "${expect_uvc_width}" ]]; then
|
||||
printf 'expected server.env UVC_WIDTH=%s but found %s\n' "${expect_uvc_width}" "${server_uvc_width:-<unset>}" >&2
|
||||
if [[ -n "${expect_uvc_width}" && "${effective_uvc_width}" != "${expect_uvc_width}" ]]; then
|
||||
printf 'expected effective UVC_WIDTH=%s but found %s\n' "${expect_uvc_width}" "${effective_uvc_width:-<unset>}" >&2
|
||||
exit 67
|
||||
fi
|
||||
if [[ -n "${expect_uvc_height}" && "${server_uvc_height}" != "${expect_uvc_height}" ]]; then
|
||||
printf 'expected server.env UVC_HEIGHT=%s but found %s\n' "${expect_uvc_height}" "${server_uvc_height:-<unset>}" >&2
|
||||
if [[ -n "${expect_uvc_height}" && "${effective_uvc_height}" != "${expect_uvc_height}" ]]; then
|
||||
printf 'expected effective UVC_HEIGHT=%s but found %s\n' "${expect_uvc_height}" "${effective_uvc_height:-<unset>}" >&2
|
||||
exit 68
|
||||
fi
|
||||
if [[ -n "${expect_uvc_fps}" && "${server_uvc_fps}" != "${expect_uvc_fps}" ]]; then
|
||||
printf 'expected server.env UVC_FPS=%s but found %s\n' "${expect_uvc_fps}" "${server_uvc_fps:-<unset>}" >&2
|
||||
if [[ -n "${expect_uvc_fps}" && "${effective_uvc_fps}" != "${expect_uvc_fps}" ]]; then
|
||||
printf 'expected effective UVC_FPS=%s but found %s\n' "${expect_uvc_fps}" "${effective_uvc_fps:-<unset>}" >&2
|
||||
exit 69
|
||||
fi
|
||||
if [[ -n "${expect_uvc_width}" && "${runtime_uvc_width}" != "${expect_uvc_width}" ]]; then
|
||||
@ -851,6 +859,81 @@ timeline_path.write_text(json.dumps(timeline, indent=2, sort_keys=True) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
compute_analysis_window_arg() {
|
||||
[[ "${ANALYSIS_TIMELINE_WINDOW}" != "0" ]] || return 0
|
||||
[[ -f "${LOCAL_SERVER_TIMELINE_JSON}" ]] || return 0
|
||||
[[ -f "${LOCAL_CAPTURE_LOG}" ]] || return 0
|
||||
[[ -f "${LOCAL_CLOCK_ALIGNMENT_JSON}" ]] || return 0
|
||||
|
||||
python3 - <<'PY' \
|
||||
"${LOCAL_SERVER_TIMELINE_JSON}" \
|
||||
"${LOCAL_CAPTURE_LOG}" \
|
||||
"${LOCAL_CLOCK_ALIGNMENT_JSON}" \
|
||||
"${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS}" || true
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
timeline_path, capture_log_path, clock_path, padding_raw = sys.argv[1:]
|
||||
|
||||
|
||||
def as_int(value):
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def as_float(value, default):
|
||||
try:
|
||||
parsed = float(str(value).strip())
|
||||
except Exception:
|
||||
return default
|
||||
return parsed if math.isfinite(parsed) else default
|
||||
|
||||
|
||||
def capture_start_ns(path):
|
||||
try:
|
||||
lines = pathlib.Path(path).read_text(errors="replace").splitlines()
|
||||
except Exception:
|
||||
return None
|
||||
for line in lines:
|
||||
if line.startswith("capture_start_unix_ns="):
|
||||
return as_int(line.split("=", 1)[1])
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
timeline = json.loads(pathlib.Path(timeline_path).read_text())
|
||||
clock = json.loads(pathlib.Path(clock_path).read_text())
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
|
||||
capture_start = capture_start_ns(capture_log_path)
|
||||
offset = as_int(clock.get("theia_to_tethys_offset_ns")) if clock.get("available") else None
|
||||
if capture_start is None or offset is None:
|
||||
raise SystemExit(0)
|
||||
|
||||
times = []
|
||||
for event in timeline.get("events", []):
|
||||
for key in ("audio_push_unix_ns", "video_feed_unix_ns"):
|
||||
event_ns = as_int(event.get(key))
|
||||
if event_ns is not None:
|
||||
times.append((event_ns + offset - capture_start) / 1_000_000_000.0)
|
||||
|
||||
if not times:
|
||||
raise SystemExit(0)
|
||||
|
||||
padding = max(0.0, as_float(padding_raw, 1.0))
|
||||
start = max(0.0, min(times) - padding)
|
||||
end = max(times) + padding
|
||||
if end <= start:
|
||||
raise SystemExit(0)
|
||||
print(f"--analysis-window-s {start:.3f}:{end:.3f}")
|
||||
PY
|
||||
}
|
||||
|
||||
write_output_delay_correlation() {
|
||||
[[ -f "${LOCAL_ANALYSIS_JSON}" ]] || return 0
|
||||
[[ -f "${LOCAL_SERVER_TIMELINE_JSON}" ]] || return 0
|
||||
@ -998,6 +1081,7 @@ def stats(rows, key):
|
||||
"first_ms": None,
|
||||
"last_ms": None,
|
||||
"mean_ms": None,
|
||||
"min_ms": None,
|
||||
"median_ms": None,
|
||||
"p95_ms": None,
|
||||
"max_ms": None,
|
||||
@ -1008,6 +1092,7 @@ def stats(rows, key):
|
||||
"first_ms": values[0],
|
||||
"last_ms": values[-1],
|
||||
"mean_ms": sum(values) / len(values),
|
||||
"min_ms": min(values),
|
||||
"median_ms": percentile(values, 50.0),
|
||||
"p95_ms": percentile(values, 95.0),
|
||||
"max_ms": max(values),
|
||||
@ -1018,7 +1103,7 @@ def shift_stats(base, delta_ms):
|
||||
shifted = dict(base)
|
||||
if not shifted.get("available"):
|
||||
return shifted
|
||||
for key in ["first_ms", "last_ms", "mean_ms", "median_ms", "p95_ms", "max_ms"]:
|
||||
for key in ["first_ms", "last_ms", "mean_ms", "min_ms", "median_ms", "p95_ms", "max_ms"]:
|
||||
value = shifted.get(key)
|
||||
shifted[key] = value + delta_ms if value is not None else None
|
||||
shifted["intentional_delay_ms"] = delta_ms
|
||||
@ -1480,6 +1565,8 @@ audio_freshness_stats = stats(joined, "audio_freshness_ms")
|
||||
video_event_age_stats = shift_stats(video_freshness_stats, video_delay_ms)
|
||||
audio_event_age_stats = shift_stats(audio_freshness_stats, audio_delay_ms)
|
||||
server_observed_correlation = correlation(joined, "server_feed_delta_ms", "observed_skew_ms")
|
||||
sync_verdict = report.get("verdict") or {}
|
||||
sync_passed = sync_verdict.get("passed") is True
|
||||
|
||||
observed_drift = observed_model.get("drift_ms", 0.0)
|
||||
server_drift = server_model.get("drift_ms", 0.0)
|
||||
@ -1532,15 +1619,37 @@ freshness_worst_event_with_uncertainty_ms = (
|
||||
if freshness_worst_event_p95_ms is not None
|
||||
else None
|
||||
)
|
||||
event_age_min_values = [
|
||||
value
|
||||
for value in [
|
||||
video_event_age_stats.get("min_ms"),
|
||||
audio_event_age_stats.get("min_ms"),
|
||||
]
|
||||
if value is not None and math.isfinite(value)
|
||||
]
|
||||
freshness_min_event_age_ms = min(event_age_min_values) if event_age_min_values else None
|
||||
if not event_age_p95_values:
|
||||
freshness_status = "unknown"
|
||||
freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available"
|
||||
elif not sync_passed:
|
||||
freshness_status = "unknown"
|
||||
freshness_reason = (
|
||||
"sync did not pass, so freshness from paired signatures is not trustworthy: "
|
||||
f"{sync_verdict.get('status', 'unknown')} - {sync_verdict.get('reason', '')}"
|
||||
)
|
||||
elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms:
|
||||
freshness_status = "unknown"
|
||||
freshness_reason = (
|
||||
f"clock uncertainty {clock_uncertainty_ms:.1f} ms exceeds "
|
||||
f"{max_clock_uncertainty_ms:.1f} ms freshness measurement limit"
|
||||
)
|
||||
elif freshness_min_event_age_ms is not None and freshness_min_event_age_ms < -clock_uncertainty_ms:
|
||||
freshness_status = "invalid"
|
||||
freshness_reason = (
|
||||
f"one media stream has impossible negative RC event age beyond clock uncertainty: "
|
||||
f"minimum event age {freshness_min_event_age_ms:.1f} ms, uncertainty "
|
||||
f"{clock_uncertainty_ms:.1f} ms"
|
||||
)
|
||||
elif freshness_worst_event_p95_ms < -clock_uncertainty_ms:
|
||||
freshness_status = "invalid"
|
||||
freshness_reason = (
|
||||
@ -1595,6 +1704,8 @@ artifact = {
|
||||
"worst_p95_path_freshness_ms": freshness_worst_p95_ms,
|
||||
"worst_event_age_p95_ms": freshness_worst_event_p95_ms,
|
||||
"worst_event_age_with_uncertainty_ms": freshness_worst_event_with_uncertainty_ms,
|
||||
"minimum_event_age_ms": freshness_min_event_age_ms,
|
||||
"sync_passed": sync_passed,
|
||||
"worst_p95_freshness_ms": freshness_worst_event_p95_ms,
|
||||
"worst_freshness_drift_ms": freshness_worst_drift_ms,
|
||||
"video_freshness_stats": video_freshness_stats,
|
||||
@ -1867,6 +1978,7 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
|
||||
"${REMOTE_PULSE_VIDEO_MODE}" \
|
||||
"${REMOTE_AUDIO_SOURCE}" \
|
||||
"${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" \
|
||||
"${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS}" \
|
||||
> >(tee "${LOCAL_CAPTURE_LOG}") \
|
||||
2> >(tee -a "${LOCAL_CAPTURE_LOG}" >&2) <<'REMOTE_CAPTURE_SCRIPT' &
|
||||
set -euo pipefail
|
||||
@ -1881,6 +1993,7 @@ remote_pulse_capture_tool=$8
|
||||
remote_pulse_video_mode=$9
|
||||
remote_audio_source=${10}
|
||||
remote_audio_quiesce_user_audio=${11}
|
||||
remote_capture_preroll_discard_seconds=${12}
|
||||
|
||||
rm -f "${remote_capture}"
|
||||
|
||||
@ -2213,7 +2326,6 @@ resolved_video_size="$(resolve_video_size "${video_size}")"
|
||||
resolved_video_fps="$(resolve_video_fps "${video_fps}")"
|
||||
printf 'using video device: %s\n' "${resolved_video_device}" >&2
|
||||
printf 'using video mode: %s @ %s fps (%s)\n' "${resolved_video_size}" "${resolved_video_fps}" "${video_format:-driver-default}" >&2
|
||||
printf '%s\n' "__LESAVKA_CAPTURE_READY__"
|
||||
video_args=(-f video4linux2 -framerate "${resolved_video_fps}" -video_size "${resolved_video_size}")
|
||||
if [[ -n "${video_format}" ]]; then
|
||||
video_args+=(-input_format "${video_format}")
|
||||
@ -2223,7 +2335,7 @@ gst_decode_chain="$(gst_video_decode_chain)"
|
||||
|
||||
run_ffmpeg_capture() {
|
||||
local rc=0
|
||||
timeout --signal=INT "$((capture_seconds + 5))" "$@" || rc=$?
|
||||
timeout --kill-after=5 --signal=INT "$((capture_seconds + 5))" "$@" </dev/null || rc=$?
|
||||
case "${rc}" in
|
||||
0|124|130)
|
||||
return 0
|
||||
@ -2259,6 +2371,53 @@ if [[ "${capture_mode}" == "alsa" && "${quiesce_for_alsa}" == "1" ]]; then
|
||||
trap restore_user_audio EXIT
|
||||
fi
|
||||
|
||||
discard_preroll_capture() {
|
||||
local seconds=$1
|
||||
[[ "${seconds}" =~ ^[0-9]+$ ]] || return 0
|
||||
[[ "${seconds}" -gt 0 ]] || return 0
|
||||
local discard="/tmp/lesavka-output-delay-preroll-discard-$RANDOM.mkv"
|
||||
printf 'discarding %ss of post-enumeration capture before probe\n' "${seconds}" >&2
|
||||
case "${capture_mode}" in
|
||||
pulse)
|
||||
if command -v ffmpeg >/dev/null 2>&1; then
|
||||
timeout --kill-after=3 --signal=INT "$((seconds + 5))" ffmpeg -nostdin -hide_banner -loglevel error -y \
|
||||
-thread_queue_size 1024 \
|
||||
"${video_args[@]}" \
|
||||
-i "${resolved_video_device}" \
|
||||
-thread_queue_size 1024 \
|
||||
-f pulse \
|
||||
-i "${pulse_source}" \
|
||||
-t "${seconds}" \
|
||||
-c:v copy \
|
||||
-c:a pcm_s16le \
|
||||
"${discard}" </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
alsa)
|
||||
if command -v ffmpeg >/dev/null 2>&1; then
|
||||
timeout --kill-after=3 --signal=INT "$((seconds + 5))" ffmpeg -nostdin -hide_banner -loglevel error -y \
|
||||
-thread_queue_size 1024 \
|
||||
"${video_args[@]}" \
|
||||
-i "${resolved_video_device}" \
|
||||
-thread_queue_size 1024 \
|
||||
-f alsa -ac 2 -ar 48000 \
|
||||
-i "${alsa_audio_dev}" \
|
||||
-t "${seconds}" \
|
||||
-c:v copy \
|
||||
-c:a pcm_s16le \
|
||||
"${discard}" </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
sleep "${seconds}"
|
||||
;;
|
||||
esac
|
||||
rm -f "${discard}"
|
||||
}
|
||||
|
||||
discard_preroll_capture "${remote_capture_preroll_discard_seconds}"
|
||||
|
||||
printf '%s\n' "__LESAVKA_CAPTURE_READY__"
|
||||
printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2
|
||||
|
||||
if [[ "${capture_mode}" == "pwpipe" ]]; then
|
||||
@ -2293,6 +2452,11 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
||||
-thread_queue_size 1024 \
|
||||
-f pulse \
|
||||
-i "${pulse_source}" \
|
||||
-f lavfi \
|
||||
-i anullsrc=channel_layout=stereo:sample_rate=48000 \
|
||||
-filter_complex "[1:a][2:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]" \
|
||||
-map 0:v:0 \
|
||||
-map "[aout]" \
|
||||
-t "${capture_seconds}" \
|
||||
-c:v copy \
|
||||
-c:a pcm_s16le \
|
||||
@ -2306,6 +2470,11 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
||||
-thread_queue_size 1024 \
|
||||
-f pulse \
|
||||
-i "${pulse_source}" \
|
||||
-f lavfi \
|
||||
-i anullsrc=channel_layout=stereo:sample_rate=48000 \
|
||||
-filter_complex "[1:a][2:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]" \
|
||||
-map 0:v:0 \
|
||||
-map "[aout]" \
|
||||
-t "${capture_seconds}" \
|
||||
-vf "fps=${resolved_video_fps}" \
|
||||
-c:v libx264 -preset ultrafast -crf 12 -g 1 -pix_fmt yuv420p \
|
||||
@ -2325,18 +2494,24 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
||||
printf 'gst copy mode only supports mjpeg input, got %s\n' "${video_format}" >&2
|
||||
exit 64
|
||||
fi
|
||||
timeout --signal=INT "$((capture_seconds + 3))" \
|
||||
timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
||||
gst-launch-1.0 -q -e \
|
||||
matroskamux name=mux ! filesink location="${remote_capture}" \
|
||||
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
||||
${gst_source_caps} ! \
|
||||
queue ! mux. \
|
||||
audiotestsrc wave=silence is-live=true samplesperbuffer=480 ! \
|
||||
audio/x-raw,rate=48000,channels=2 ! \
|
||||
queue ! mix. \
|
||||
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
||||
audio/x-raw,rate=48000,channels=2 ! \
|
||||
audioconvert ! audioresample ! queue ! mux. || true
|
||||
audioconvert ! audioresample ! queue ! mix. \
|
||||
audiomixer name=mix ! \
|
||||
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
||||
queue ! mux. || true
|
||||
;;
|
||||
cfr)
|
||||
timeout --signal=INT "$((capture_seconds + 3))" \
|
||||
timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
||||
gst-launch-1.0 -q -e \
|
||||
matroskamux name=mux ! filesink location="${remote_capture}" \
|
||||
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
||||
@ -2346,9 +2521,15 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
||||
x264enc tune=zerolatency speed-preset=ultrafast key-int-max=1 bitrate=5000 ! \
|
||||
h264parse ! \
|
||||
queue ! mux. \
|
||||
audiotestsrc wave=silence is-live=true samplesperbuffer=480 ! \
|
||||
audio/x-raw,rate=48000,channels=2 ! \
|
||||
queue ! mix. \
|
||||
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
||||
audio/x-raw,rate=48000,channels=2 ! \
|
||||
audioconvert ! audioresample ! queue ! mux. || true
|
||||
audioconvert ! audioresample ! queue ! mix. \
|
||||
audiomixer name=mix ! \
|
||||
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
||||
queue ! mux. || true
|
||||
;;
|
||||
*)
|
||||
printf 'unsupported REMOTE_PULSE_VIDEO_MODE=%s\n' "${remote_pulse_video_mode}" >&2
|
||||
@ -2475,9 +2656,13 @@ REMOTE_NORMALIZE_SCRIPT
|
||||
echo "==> copying sync analyzer to ${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}"
|
||||
scp ${SSH_OPTS} "${ANALYZE_BIN}" "${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}"
|
||||
fi
|
||||
analysis_window_arg="$(compute_analysis_window_arg)"
|
||||
if [[ -n "${analysis_window_arg}" ]]; then
|
||||
echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }"
|
||||
fi
|
||||
echo "==> analyzing capture on ${TETHYS_HOST}"
|
||||
ssh ${SSH_OPTS} "${TETHYS_HOST}" \
|
||||
"chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}'" \
|
||||
"chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}' ${analysis_window_arg}" \
|
||||
> "${LOCAL_ANALYSIS_JSON}"
|
||||
fi
|
||||
|
||||
@ -2570,9 +2755,14 @@ else
|
||||
exit 93
|
||||
fi
|
||||
echo "==> analyzing capture"
|
||||
analysis_window_arg="$(compute_analysis_window_arg)"
|
||||
if [[ -n "${analysis_window_arg}" ]]; then
|
||||
echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }"
|
||||
fi
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
"${ANALYZE_BIN}" "${LOCAL_CAPTURE}" --report-dir "${LOCAL_REPORT_DIR}" --event-width-codes "${PROBE_EVENT_WIDTH_CODES}"
|
||||
# shellcheck disable=SC2086
|
||||
"${ANALYZE_BIN}" "${LOCAL_CAPTURE}" --report-dir "${LOCAL_REPORT_DIR}" --event-width-codes "${PROBE_EVENT_WIDTH_CODES}" ${analysis_window_arg}
|
||||
)
|
||||
fi
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.19.14"
|
||||
version = "0.19.15"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -56,12 +56,19 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS:-1000}",
|
||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100}",
|
||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}",
|
||||
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}",
|
||||
"discarding %ss of post-enumeration capture before probe",
|
||||
"ffmpeg -nostdin -hide_banner",
|
||||
"\"$@\" </dev/null",
|
||||
"timeout --kill-after=5 --signal=INT",
|
||||
"timeout --kill-after=3 --signal=INT",
|
||||
"REMOTE_EXPECT_UVC_WIDTH=${REMOTE_EXPECT_UVC_WIDTH:-}",
|
||||
"REMOTE_EXPECT_UVC_HEIGHT=${REMOTE_EXPECT_UVC_HEIGHT:-}",
|
||||
"REMOTE_EXPECT_UVC_FPS=${REMOTE_EXPECT_UVC_FPS:-}",
|
||||
"server.env UVC_MODE=",
|
||||
"uvc.env UVC_MODE=",
|
||||
"expected server.env UVC_WIDTH",
|
||||
"effective UVC_MODE=",
|
||||
"expected effective UVC_WIDTH",
|
||||
"expected uvc.env UVC_FPS",
|
||||
"server-to-capture clock alignment unavailable; falling back to client-mediated SSH samples",
|
||||
"LESAVKA_CLOCK_ALIGNMENT_SAMPLES=${LESAVKA_CLOCK_ALIGNMENT_SAMPLES:-5}",
|
||||
@ -103,6 +110,10 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
||||
"intentional_video_delay_ms",
|
||||
"video_event_age_stats",
|
||||
"worst_event_age_with_uncertainty_ms",
|
||||
"minimum_event_age_ms",
|
||||
"sync_passed",
|
||||
"sync did not pass, so freshness from paired signatures is not trustworthy",
|
||||
"impossible negative RC event age",
|
||||
"video_delay_function_candidate",
|
||||
"source\": \"direct-uvc-uac-output-probe\"",
|
||||
"scope\": \"server-output-static-baseline\"",
|
||||
@ -133,6 +144,9 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
||||
"calibration-save-default",
|
||||
"LEAD_IN_SECONDS=${LEAD_IN_SECONDS:-0}",
|
||||
"PROBE_TIMEOUT_SECONDS=${PROBE_TIMEOUT_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + 20))}",
|
||||
"ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-1}",
|
||||
"compute_analysis_window_arg",
|
||||
"analyzer timeline window:",
|
||||
"output-delay-probe",
|
||||
"server-generated UVC/UAC output-delay probe",
|
||||
"server output-delay probe timed out after ${PROBE_TIMEOUT_SECONDS}s",
|
||||
@ -147,6 +161,11 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
||||
"resolve_alsa_audio_device",
|
||||
"PipeWire Lesavka source not found; falling back to ALSA device",
|
||||
"Lesavka audio source not found in PipeWire or ALSA; capture host does not currently expose the gadget microphone.",
|
||||
"discarding %ss of post-enumeration capture before probe",
|
||||
"audiotestsrc wave=silence is-live=true samplesperbuffer=480",
|
||||
"audiomixer name=mix",
|
||||
"anullsrc=channel_layout=stereo:sample_rate=48000",
|
||||
"amix=inputs=2:duration=longest",
|
||||
"artifact_dir: ${LOCAL_REPORT_DIR}",
|
||||
"events_csv: ${LOCAL_EVENTS_CSV}",
|
||||
"server_timeline_json: ${LOCAL_SERVER_TIMELINE_JSON}",
|
||||
@ -186,18 +205,47 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
||||
#[test]
|
||||
fn server_rc_mode_matrix_validates_advertised_uvc_profiles() {
|
||||
for expected in [
|
||||
"LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-640x480@20,1280x720@30,1920x1080@20,1920x1080@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_REPO=${LESAVKA_SERVER_REPO:-auto}",
|
||||
"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:-170000}",
|
||||
"LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-640x480@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}",
|
||||
"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}}",
|
||||
"LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-1280x720@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}",
|
||||
"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}",
|
||||
"LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX:-Lesavka|UGREEN|MACROSILICON|Composite|Capture}",
|
||||
"LESAVKA_SERVER_RC_MODE_SOURCE=${LESAVKA_SERVER_RC_MODE_SOURCE:-configured}",
|
||||
"LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}",
|
||||
"LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0}",
|
||||
"could not find a Lesavka checkout on this server host",
|
||||
"Set LESAVKA_SERVER_REPO=/path/to/lesavka",
|
||||
"/home/theia/Development/lesavka",
|
||||
"using server repo:",
|
||||
"server checkout has local changes; refusing to update it for the mode matrix",
|
||||
"leaving server checkout untouched",
|
||||
"LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY:-runtime}",
|
||||
"LESAVKA_SERVER_RC_ALLOW_GADGET_RESET=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET:-1}",
|
||||
"LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE=${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE:-0}",
|
||||
"LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY:-1}",
|
||||
"LESAVKA_SERVER_RC_WAIT_TETHYS_READY=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY:-1}",
|
||||
"LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS:-60}",
|
||||
"LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS:-6}",
|
||||
"LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS:-3}",
|
||||
"LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1}",
|
||||
"Theia sudo password for %s",
|
||||
"==> priming remote sudo on ${LESAVKA_SERVER_HOST}",
|
||||
"==> prebuilding relay control/analyzer once for the mode matrix",
|
||||
"LESAVKA_SERVER_RC_MODES=auto",
|
||||
"discover_local_webcam_modes",
|
||||
"lookup_audio_delay_us",
|
||||
"local webcam",
|
||||
"mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}",
|
||||
"video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}",
|
||||
"audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}",
|
||||
"fast runtime env updated: CAM_OUTPUT=uvc",
|
||||
"cycling UVC gadget descriptors",
|
||||
"lesavka-core reconfigure log:",
|
||||
"missing /usr/local/bin/lesavka-core.sh",
|
||||
"wait_tethys_media_ready",
|
||||
"==> waiting for Tethys media endpoints for ${mode}",
|
||||
"Tethys media ready: video=%s mode=%s audio_stack=%s",
|
||||
"timed out waiting for Tethys Lesavka media endpoints",
|
||||
"LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}",
|
||||
"LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS=${LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS:-0}",
|
||||
"LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS=${LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS:-0}",
|
||||
@ -207,13 +255,21 @@ fn server_rc_mode_matrix_validates_advertised_uvc_profiles() {
|
||||
"mode-matrix-summary.txt",
|
||||
"schema\": \"lesavka.server-rc-mode-result.v1\"",
|
||||
"schema\": \"lesavka.server-rc-mode-matrix-summary.v1\"",
|
||||
"output_delay_calibration",
|
||||
"calibration_ready",
|
||||
"calibration_video_target_offset_us",
|
||||
"calibration_audio_target_offset_us",
|
||||
"calibration:",
|
||||
"REMOTE_PULSE_CAPTURE_TOOL=\"${REMOTE_PULSE_CAPTURE_TOOL}\"",
|
||||
"REMOTE_PULSE_VIDEO_MODE=\"${REMOTE_PULSE_VIDEO_MODE}\"",
|
||||
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=\"${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}\"",
|
||||
"PROBE_PREBUILD=0",
|
||||
"VIDEO_SIZE=\"${width}x${height}\"",
|
||||
"VIDEO_FPS=\"${fps}\"",
|
||||
"REMOTE_EXPECT_UVC_WIDTH=\"${width}\"",
|
||||
"REMOTE_EXPECT_UVC_HEIGHT=\"${height}\"",
|
||||
"REMOTE_EXPECT_UVC_FPS=\"${fps}\"",
|
||||
"LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US=\"${audio_delay_us}\"",
|
||||
"LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=\"${video_delay_us}\"",
|
||||
"LESAVKA_OUTPUT_DELAY_APPLY=0",
|
||||
"LESAVKA_OUTPUT_DELAY_SAVE=0",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user