From 59725fcbfb0ea6a6ff91969fc32a270b2338a61f Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 2 May 2026 13:44:32 -0300 Subject: [PATCH] media: add probe-driven calibration tooling --- AGENTS.md | 17 ++ Cargo.lock | 6 +- client/Cargo.toml | 2 +- client/src/bin/lesavka-relayctl.rs | 241 +++++++++++++++++- common/Cargo.toml | 2 +- .../manual/run_upstream_mirrored_av_sync.sh | 134 ++++++++++ server/Cargo.toml | 2 +- .../client_manual_sync_script_contract.rs | 10 + .../tests/client_relayctl_binary_contract.rs | 17 ++ 9 files changed, 419 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c10954a..8496017 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -298,3 +298,20 @@ Context: 0.17.11 installed cleanly on both ends (`092c03a`) and fixed the consta - [x] Update contract tests to encode the new audio continuity policy. - [x] Run focused client queue/probe contracts and package checks. - [x] Push clean semver `0.17.12` for installed client/server testing. + +## 0.17.13 Probe-Driven Calibration Loop Checklist + +Context: 0.17.12 installed cleanly on both ends (`2b26fde`) and moved the paired-pulse median into the near-sync zone (`median=+71.6ms`, first/last `+59.8ms/-60.1ms`), but the run still failed with only 4 usable coded pairs and `p95=206.8ms`. Static guesses have reached diminishing returns. 0.17.13 adds a measured calibration loop so the mirrored probe can become the authority for site-specific browser-visible output compensation when, and only when, the analyzer has enough stable evidence. + +- [x] Keep 0.17.13 scoped to probe-driven sync calibration tooling; do not change freshness ceilings, queue policy, UAC smoothness, or startup healing behavior. +- [x] Expose relay CLI calibration state and safe calibration actions for scripts. +- [x] Make the mirrored probe locate the analyzer `report.json` after a run and print the calibration decision. +- [x] Add opt-in probe calibration apply mode gated by `LESAVKA_SYNC_APPLY_CALIBRATION=1`. +- [x] Apply only when analyzer `calibration.ready=true`; otherwise refuse and print the analyzer reason. +- [x] Default probe-driven correction to video offset adjustment, because the measured residual is browser-visible UAC egress delay relative to UVC video. +- [x] Keep saving the measured offset as the site default opt-in via `LESAVKA_SYNC_SAVE_CALIBRATION=1`. +- [x] Update contract tests for relay CLI and manual probe script behavior. +- [x] Run focused relay/manual-script tests and package checks. +- [ ] Push clean semver `0.17.13` for installed client/server testing. + +Follow-up candidate: after 0.17.13 proves safe measured apply/refuse behavior, add a segmented live-calibration probe. The current browser probe uploads one WebM after recording ends, so it can only do measure/apply/rerun. A true same-session loop should run a longer stimulus, capture/analyze separate Tethys browser windows, apply calibration only between windows, and use the next window as the confirmation segment so before/after evidence is not mixed. diff --git a/Cargo.lock b/Cargo.lock index e9c4c7b..f9a4c94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.17.12" +version = "0.17.13" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.17.12" +version = "0.17.13" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.17.12" +version = "0.17.13" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 8f2dcb3..d4195de 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.17.12" +version = "0.17.13" edition = "2024" [dependencies] diff --git a/client/src/bin/lesavka-relayctl.rs b/client/src/bin/lesavka-relayctl.rs index 650a007..4267686 100644 --- a/client/src/bin/lesavka-relayctl.rs +++ b/client/src/bin/lesavka-relayctl.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result, bail}; use lesavka_common::lesavka::{ - CapturePowerCommand, HandshakeSet, SetCapturePowerRequest, relay_client::RelayClient, + CalibrationAction, CalibrationRequest, CalibrationState, CapturePowerCommand, HandshakeSet, + SetCapturePowerRequest, relay_client::RelayClient, }; #[cfg(not(coverage))] use lesavka_common::lesavka::{Empty, handshake_client::HandshakeClient}; @@ -14,6 +15,11 @@ use lesavka_client::relay_transport; enum CommandKind { Status, Version, + CalibrationStatus, + CalibrationAdjust, + CalibrationRestoreDefault, + CalibrationRestoreFactory, + CalibrationSaveDefault, Auto, On, Off, @@ -30,6 +36,15 @@ impl CommandKind { match value { "status" | "get" => Some(Self::Status), "version" | "versions" => Some(Self::Version), + "calibration" | "calibration-status" => Some(Self::CalibrationStatus), + "calibrate" | "calibration-adjust" => Some(Self::CalibrationAdjust), + "calibration-restore-default" | "restore-calibration" => { + Some(Self::CalibrationRestoreDefault) + } + "calibration-restore-factory" | "factory-calibration" => { + Some(Self::CalibrationRestoreFactory) + } + "calibration-save-default" | "save-calibration" => Some(Self::CalibrationSaveDefault), "auto" => Some(Self::Auto), "on" | "force-on" => Some(Self::On), "off" | "force-off" => Some(Self::Off), @@ -47,6 +62,9 @@ impl CommandKind { struct Config { server: String, command: CommandKind, + audio_delta_us: i64, + video_delta_us: i64, + note: String, } #[derive(Debug, Eq, PartialEq)] @@ -56,7 +74,7 @@ enum ParseOutcome { } fn usage() -> &'static str { - "Usage: lesavka-relayctl [--server http://HOST:50051] " + "Usage: lesavka-relayctl [--server http://HOST:50051] [note]|calibration-save-default|calibration-restore-default|calibration-restore-factory|auto|on|off|recover-usb|recover-uac|recover-uvc|reset-usb>" } fn parse_args_outcome_from(args: I) -> Result @@ -67,6 +85,7 @@ where let mut args = args.into_iter().map(Into::into); let mut server = "http://127.0.0.1:50051".to_string(); let mut command = None; + let mut command_args = Vec::new(); while let Some(arg) = args.next() { match arg.as_str() { @@ -86,16 +105,46 @@ where bail!("unknown command `{arg}`\n{}", usage()); } } - _ => bail!("unexpected argument `{arg}`\n{}", usage()), + _ => command_args.push(arg), } } + let command = command.unwrap_or(CommandKind::Status); + let (audio_delta_us, video_delta_us, note) = parse_command_args(command, command_args)?; Ok(ParseOutcome::Run(Config { server, - command: command.unwrap_or(CommandKind::Status), + command, + audio_delta_us, + video_delta_us, + note, })) } +fn parse_command_args(command: CommandKind, args: Vec) -> Result<(i64, i64, String)> { + if command != CommandKind::CalibrationAdjust { + if let Some(arg) = args.first() { + bail!("unexpected argument `{arg}`\n{}", usage()); + } + return Ok((0, 0, String::new())); + } + + if args.len() < 2 { + bail!( + "calibrate requires audio_delta_us and video_delta_us\n{}", + usage() + ); + } + + let audio_delta_us = args[0] + .parse::() + .with_context(|| format!("parsing audio_delta_us `{}`", args[0]))?; + let video_delta_us = args[1] + .parse::() + .with_context(|| format!("parsing video_delta_us `{}`", args[1]))?; + let note = args[2..].join(" "); + Ok((audio_delta_us, video_delta_us, note)) +} + #[cfg(test)] fn parse_args_from(args: I) -> Result where @@ -116,6 +165,11 @@ fn capture_power_request(command: CommandKind) -> Option let (enabled, command) = match command { CommandKind::Status | CommandKind::Version + | CommandKind::CalibrationStatus + | CommandKind::CalibrationAdjust + | CommandKind::CalibrationRestoreDefault + | CommandKind::CalibrationRestoreFactory + | CommandKind::CalibrationSaveDefault | CommandKind::RecoverUsb | CommandKind::RecoverUac | CommandKind::RecoverUvc @@ -221,6 +275,66 @@ fn print_upstream_sync(state: lesavka_common::lesavka::UpstreamSyncState) { println!("planner_detail={}", state.last_reason); } +fn print_calibration_state(state: CalibrationState) { + println!("calibration_profile={}", state.profile); + println!( + "calibration_factory_audio_offset_us={}", + state.factory_audio_offset_us + ); + println!( + "calibration_factory_video_offset_us={}", + state.factory_video_offset_us + ); + println!( + "calibration_default_audio_offset_us={}", + state.default_audio_offset_us + ); + println!( + "calibration_default_video_offset_us={}", + state.default_video_offset_us + ); + println!( + "calibration_active_audio_offset_us={}", + state.active_audio_offset_us + ); + println!( + "calibration_active_video_offset_us={}", + state.active_video_offset_us + ); + println!("calibration_source={}", state.source); + println!("calibration_confidence={}", state.confidence); + println!("calibration_updated_at={}", state.updated_at); + println!("calibration_detail={}", state.detail); +} + +fn calibration_request_for(config: &Config) -> Option { + let action = match config.command { + CommandKind::CalibrationAdjust => CalibrationAction::AdjustActive, + CommandKind::CalibrationRestoreDefault => CalibrationAction::RestoreDefault, + CommandKind::CalibrationRestoreFactory => CalibrationAction::RestoreFactory, + CommandKind::CalibrationSaveDefault => CalibrationAction::SaveActiveAsDefault, + CommandKind::Status + | CommandKind::Version + | CommandKind::CalibrationStatus + | CommandKind::Auto + | CommandKind::On + | CommandKind::Off + | CommandKind::RecoverUsb + | CommandKind::RecoverUac + | CommandKind::RecoverUvc + | CommandKind::ResetUsb + | CommandKind::UpstreamSync => return None, + }; + Some(CalibrationRequest { + action: action as i32, + audio_delta_us: config.audio_delta_us, + video_delta_us: config.video_delta_us, + observed_delivery_skew_ms: 0.0, + observed_enqueue_skew_ms: 0.0, + note: config.note.clone(), + }) +} + #[cfg(not(coverage))] #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { @@ -247,6 +361,11 @@ async fn main() -> Result<()> { CommandKind::Off => "forcing capture power off", CommandKind::Status | CommandKind::Version + | CommandKind::CalibrationStatus + | CommandKind::CalibrationAdjust + | CommandKind::CalibrationRestoreDefault + | CommandKind::CalibrationRestoreFactory + | CommandKind::CalibrationSaveDefault | CommandKind::RecoverUsb | CommandKind::RecoverUac | CommandKind::RecoverUvc @@ -262,6 +381,16 @@ async fn main() -> Result<()> { return Ok(()); } + if let Some(request) = calibration_request_for(&config) { + let reply = client + .calibrate(Request::new(request)) + .await + .context("applying upstream A/V calibration")? + .into_inner(); + print_calibration_state(reply); + return Ok(()); + } + let reply = match config.command { CommandKind::Status => client .get_capture_power(Request::new(Empty {})) @@ -313,9 +442,22 @@ async fn main() -> Result<()> { print_upstream_sync(reply); return Ok(()); } + CommandKind::CalibrationStatus => { + let reply = client + .get_calibration(Request::new(Empty {})) + .await + .context("querying upstream A/V calibration")? + .into_inner(); + print_calibration_state(reply); + return Ok(()); + } CommandKind::Version | CommandKind::Auto | CommandKind::On | CommandKind::Off => { unreachable!() } + CommandKind::CalibrationAdjust + | CommandKind::CalibrationRestoreDefault + | CommandKind::CalibrationRestoreFactory + | CommandKind::CalibrationSaveDefault => unreachable!(), }; print_state(reply); @@ -330,8 +472,8 @@ fn main() { #[cfg(test)] mod tests { use super::{ - CapturePowerCommand, CommandKind, ParseOutcome, capture_power_request, parse_args_from, - parse_args_outcome_from, + CalibrationAction, CapturePowerCommand, CommandKind, Config, ParseOutcome, + calibration_request_for, capture_power_request, parse_args_from, parse_args_outcome_from, }; use lesavka_common::lesavka::CapturePowerState; @@ -342,6 +484,26 @@ mod tests { assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("version"), Some(CommandKind::Version)); assert_eq!(CommandKind::parse("versions"), Some(CommandKind::Version)); + assert_eq!( + CommandKind::parse("calibration"), + Some(CommandKind::CalibrationStatus) + ); + assert_eq!( + CommandKind::parse("calibrate"), + Some(CommandKind::CalibrationAdjust) + ); + assert_eq!( + CommandKind::parse("calibration-restore-default"), + Some(CommandKind::CalibrationRestoreDefault) + ); + assert_eq!( + CommandKind::parse("calibration-restore-factory"), + Some(CommandKind::CalibrationRestoreFactory) + ); + assert_eq!( + CommandKind::parse("calibration-save-default"), + Some(CommandKind::CalibrationSaveDefault) + ); assert_eq!( CommandKind::parse("upstream-sync"), Some(CommandKind::UpstreamSync) @@ -373,6 +535,9 @@ mod tests { let config = parse_args_from(std::iter::empty::<&str>()).expect("default config"); assert_eq!(config.server, "http://127.0.0.1:50051"); assert_eq!(config.command, CommandKind::Status); + assert_eq!(config.audio_delta_us, 0); + assert_eq!(config.video_delta_us, 0); + assert!(config.note.is_empty()); } #[test] @@ -383,11 +548,31 @@ mod tests { assert_eq!(config.command, CommandKind::UpstreamSync); } + #[test] + fn parse_args_accepts_calibration_adjustment() { + let config = parse_args_from([ + "--server", + "http://lab:50051", + "calibrate", + "0", + "71600", + "probe", + "median", + ]) + .expect("calibration config"); + assert_eq!(config.command, CommandKind::CalibrationAdjust); + assert_eq!(config.audio_delta_us, 0); + assert_eq!(config.video_delta_us, 71_600); + assert_eq!(config.note, "probe median"); + } + #[test] fn parse_args_rejects_bad_inputs() { assert!(parse_args_from(["--server"]).is_err()); assert!(parse_args_from(["nope"]).is_err()); assert!(parse_args_from(["status", "extra"]).is_err()); + assert!(parse_args_from(["calibrate"]).is_err()); + assert!(parse_args_from(["calibrate", "0", "not-int"]).is_err()); } #[test] @@ -437,6 +622,11 @@ mod tests { assert!(capture_power_request(CommandKind::Status).is_none()); assert!(capture_power_request(CommandKind::Version).is_none()); + assert!(capture_power_request(CommandKind::CalibrationStatus).is_none()); + assert!(capture_power_request(CommandKind::CalibrationAdjust).is_none()); + assert!(capture_power_request(CommandKind::CalibrationRestoreDefault).is_none()); + assert!(capture_power_request(CommandKind::CalibrationRestoreFactory).is_none()); + assert!(capture_power_request(CommandKind::CalibrationSaveDefault).is_none()); assert!(capture_power_request(CommandKind::RecoverUsb).is_none()); assert!(capture_power_request(CommandKind::RecoverUac).is_none()); assert!(capture_power_request(CommandKind::RecoverUvc).is_none()); @@ -456,4 +646,43 @@ mod tests { detail: "ready".to_string(), }); } + + #[test] + fn print_calibration_accepts_full_payload() { + super::print_calibration_state(lesavka_common::lesavka::CalibrationState { + profile: "mjpeg".to_string(), + factory_audio_offset_us: 0, + factory_video_offset_us: 1_090_000, + default_audio_offset_us: 0, + default_video_offset_us: 1_090_000, + active_audio_offset_us: 0, + active_video_offset_us: 1_161_600, + source: "manual".to_string(), + confidence: "manual".to_string(), + updated_at: "2026-05-02T00:00:00Z".to_string(), + detail: "probe nudge".to_string(), + }); + } + + #[test] + fn calibration_requests_are_only_built_for_calibration_mutations() { + let config = Config { + server: "http://127.0.0.1:50051".to_string(), + command: CommandKind::CalibrationAdjust, + audio_delta_us: 0, + video_delta_us: 71_600, + note: "probe".to_string(), + }; + let request = calibration_request_for(&config).expect("request"); + assert_eq!(request.action, CalibrationAction::AdjustActive as i32); + assert_eq!(request.audio_delta_us, 0); + assert_eq!(request.video_delta_us, 71_600); + assert_eq!(request.note, "probe"); + + let status = Config { + command: CommandKind::CalibrationStatus, + ..config + }; + assert!(calibration_request_for(&status).is_none()); + } } diff --git a/common/Cargo.toml b/common/Cargo.toml index 4f04824..abd1edc 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.17.12" +version = "0.17.13" edition = "2024" build = "build.rs" diff --git a/scripts/manual/run_upstream_mirrored_av_sync.sh b/scripts/manual/run_upstream_mirrored_av_sync.sh index a29868c..930d6da 100755 --- a/scripts/manual/run_upstream_mirrored_av_sync.sh +++ b/scripts/manual/run_upstream_mirrored_av_sync.sh @@ -23,6 +23,9 @@ PROBE_PULSE_PERIOD_MS=${PROBE_PULSE_PERIOD_MS:-1000} PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120} PROBE_MARKER_TICK_PERIOD=${PROBE_MARKER_TICK_PERIOD:-5} PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2} +LESAVKA_SYNC_APPLY_CALIBRATION=${LESAVKA_SYNC_APPLY_CALIBRATION:-0} +LESAVKA_SYNC_SAVE_CALIBRATION=${LESAVKA_SYNC_SAVE_CALIBRATION:-0} +LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video} STIMULUS_PORT=${STIMULUS_PORT:-18444} STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10} LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"} @@ -221,6 +224,134 @@ print_upstream_sync_state() { done <<<"${sync_output}" } +print_upstream_calibration_state() { + local label="$1" + echo "==> upstream calibration state (${label})" + if [[ ! -x "${REPO_ROOT}/target/debug/lesavka-relayctl" ]]; then + (cd "${REPO_ROOT}" && cargo build -p lesavka_client --bin lesavka-relayctl >/dev/null) + fi + local calibration_output + if ! calibration_output="$( + LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \ + "${REPO_ROOT}/target/debug/lesavka-relayctl" \ + --server "${RESOLVED_LESAVKA_SERVER_ADDR}" \ + calibration 2>&1 + )"; then + echo " ↪ calibration query failed: ${calibration_output}" + return 0 + fi + while IFS= read -r line; do + [[ -n "${line}" ]] && echo " ↪ ${line}" + done <<<"${calibration_output}" +} + +latest_report_json() { + find "${ARTIFACT_DIR}" -mindepth 2 -maxdepth 2 -type f -name report.json -printf '%T@ %p\n' 2>/dev/null \ + | sort -n \ + | tail -n 1 \ + | cut -d' ' -f2- +} + +maybe_apply_probe_calibration() { + local report_json + report_json="$(latest_report_json)" + echo "==> probe calibration decision" + if [[ -z "${report_json}" || ! -f "${report_json}" ]]; then + echo " ↪ report_json=missing" + echo " ↪ calibration apply skipped: analyzer report was not produced" + return 0 + fi + + local summary + if ! summary="$(python3 - "${report_json}" "${LESAVKA_SYNC_CALIBRATION_TARGET}" <<'PY' +import json +import shlex +import sys + +report_path = sys.argv[1] +target = sys.argv[2].strip().lower() +with open(report_path, "r", encoding="utf-8") as handle: + report = json.load(handle) + +cal = report.get("calibration", {}) +verdict = report.get("verdict", {}) +if target not in {"audio", "video"}: + target = "video" + +audio_recommendation = int(cal.get("recommended_audio_offset_adjust_us") or 0) +video_recommendation = int(cal.get("recommended_video_offset_adjust_us") or 0) +audio_delta = audio_recommendation if target == "audio" else 0 +video_delta = video_recommendation if target == "video" else 0 + +fields = { + "report_json": report_path, + "calibration_ready": str(bool(cal.get("ready"))).lower(), + "calibration_target": target, + "calibration_audio_recommendation_us": audio_recommendation, + "calibration_video_recommendation_us": video_recommendation, + "calibration_apply_audio_delta_us": audio_delta, + "calibration_apply_video_delta_us": video_delta, + "calibration_note": cal.get("note", ""), + "verdict_status": verdict.get("status", ""), + "paired_pulses": report.get("paired_event_count", 0), + "median_skew_ms": f"{float(report.get('median_skew_ms', 0.0)):+.1f}", + "p95_abs_skew_ms": f"{float(verdict.get('p95_abs_skew_ms', 0.0)):.1f}", + "drift_ms": f"{float(report.get('drift_ms', 0.0)):+.1f}", +} +for key, value in fields.items(): + print(f"{key}={shlex.quote(str(value))}") +PY + )"; then + echo " ↪ failed to parse ${report_json}; calibration apply skipped" >&2 + return 0 + fi + + eval "${summary}" + echo " ↪ report_json=${report_json}" + echo " ↪ verdict_status=${verdict_status}" + echo " ↪ paired_pulses=${paired_pulses}" + echo " ↪ median_skew_ms=${median_skew_ms}" + echo " ↪ p95_abs_skew_ms=${p95_abs_skew_ms}" + echo " ↪ drift_ms=${drift_ms}" + echo " ↪ calibration_ready=${calibration_ready}" + echo " ↪ calibration_target=${calibration_target}" + echo " ↪ recommended_audio_offset_adjust_us=${calibration_audio_recommendation_us}" + echo " ↪ recommended_video_offset_adjust_us=${calibration_video_recommendation_us}" + echo " ↪ calibration_note=${calibration_note}" + + if [[ "${LESAVKA_SYNC_APPLY_CALIBRATION}" != "1" ]]; then + echo " ↪ calibration apply disabled; set LESAVKA_SYNC_APPLY_CALIBRATION=1 to apply a ready recommendation" + return 0 + fi + if [[ "${calibration_ready}" != "true" ]]; then + echo " ↪ calibration apply refused: analyzer did not mark this report calibration-ready" + return 0 + fi + if [[ "${calibration_apply_audio_delta_us}" == "0" && "${calibration_apply_video_delta_us}" == "0" ]]; then + echo " ↪ calibration apply skipped: recommended delta is already zero" + return 0 + fi + + local note="mirrored probe ${STAMP}: target=${calibration_target}, median=${median_skew_ms}ms, p95=${p95_abs_skew_ms}ms, pairs=${paired_pulses}" + echo " ↪ applying calibration: audio_delta_us=${calibration_apply_audio_delta_us}, video_delta_us=${calibration_apply_video_delta_us}" + LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \ + "${REPO_ROOT}/target/debug/lesavka-relayctl" \ + --server "${RESOLVED_LESAVKA_SERVER_ADDR}" \ + calibrate "${calibration_apply_audio_delta_us}" "${calibration_apply_video_delta_us}" "${note}" \ + | sed 's/^/ ↪ /' + + if [[ "${LESAVKA_SYNC_SAVE_CALIBRATION}" == "1" ]]; then + echo " ↪ saving active calibration as site default" + LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \ + "${REPO_ROOT}/target/debug/lesavka-relayctl" \ + --server "${RESOLVED_LESAVKA_SERVER_ADDR}" \ + calibration-save-default \ + | sed 's/^/ ↪ /' + else + echo " ↪ active calibration not saved; set LESAVKA_SYNC_SAVE_CALIBRATION=1 after a confirming rerun" + fi +} + start_local_stimulus() { echo "==> starting local A/V stimulus server" python3 "${REPO_ROOT}/scripts/manual/local_av_stimulus.py" \ @@ -303,12 +434,15 @@ echo "==> prebuilding real client and analyzer" start_server_tunnel_if_needed print_lesavka_versions +print_upstream_calibration_state "before mirrored run" print_upstream_sync_state "before mirrored run" start_local_stimulus start_real_lesavka_client run_status=0 run_browser_capture_with_real_driver || run_status=$? +maybe_apply_probe_calibration print_upstream_sync_state "after mirrored run" +print_upstream_calibration_state "after mirrored run" if ((run_status != 0)); then echo "==> mirrored probe failed" diff --git a/server/Cargo.toml b/server/Cargo.toml index 94bf844..55441c3 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.17.12" +version = "0.17.13" edition = "2024" autobins = false diff --git a/testing/tests/client_manual_sync_script_contract.rs b/testing/tests/client_manual_sync_script_contract.rs index 6fb8669..3bf6052 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -111,7 +111,17 @@ fn mirrored_sync_script_uses_real_client_capture_path() { "combined version+revision", "run_status=0", "run_browser_capture_with_real_driver || run_status=$?", + "LESAVKA_SYNC_APPLY_CALIBRATION=${LESAVKA_SYNC_APPLY_CALIBRATION:-0}", + "LESAVKA_SYNC_SAVE_CALIBRATION=${LESAVKA_SYNC_SAVE_CALIBRATION:-0}", + "LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video}", + "print_upstream_calibration_state \"before mirrored run\"", + "maybe_apply_probe_calibration", + "calibration_ready=${calibration_ready}", + "calibration apply refused: analyzer did not mark this report calibration-ready", + "calibrate \"${calibration_apply_audio_delta_us}\" \"${calibration_apply_video_delta_us}\"", + "calibration-save-default", "print_upstream_sync_state \"after mirrored run\"", + "print_upstream_calibration_state \"after mirrored run\"", "==> mirrored probe failed", ] { assert!( diff --git a/testing/tests/client_relayctl_binary_contract.rs b/testing/tests/client_relayctl_binary_contract.rs index 17462bc..8dbbbf3 100644 --- a/testing/tests/client_relayctl_binary_contract.rs +++ b/testing/tests/client_relayctl_binary_contract.rs @@ -14,6 +14,22 @@ mod relayctl_binary { fn command_aliases_and_usage_are_stable() { assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status)); + assert_eq!( + CommandKind::parse("calibration"), + Some(CommandKind::CalibrationStatus) + ); + assert_eq!( + CommandKind::parse("calibration-status"), + Some(CommandKind::CalibrationStatus) + ); + assert_eq!( + CommandKind::parse("calibrate"), + Some(CommandKind::CalibrationAdjust) + ); + assert_eq!( + CommandKind::parse("calibration-save-default"), + Some(CommandKind::CalibrationSaveDefault) + ); assert_eq!(CommandKind::parse("auto"), Some(CommandKind::Auto)); assert_eq!(CommandKind::parse("on"), Some(CommandKind::On)); assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On)); @@ -40,6 +56,7 @@ mod relayctl_binary { assert!(usage().contains("lesavka-relayctl")); assert!(usage().contains("recover-uac")); assert!(usage().contains("recover-uvc")); + assert!(usage().contains("calibrate ")); } #[tokio::test(flavor = "current_thread")]