media: add probe-driven calibration tooling
This commit is contained in:
parent
2b26fded66
commit
59725fcbfb
17
AGENTS.md
17
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.
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.17.12"
|
||||
version = "0.17.13"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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] <status|version|upstream-sync|auto|on|off|recover-usb|recover-uac|recover-uvc|reset-usb>"
|
||||
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|version|upstream-sync|calibration|calibrate <audio_delta_us> <video_delta_us> [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<I, S>(args: I) -> Result<ParseOutcome>
|
||||
@ -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<String>) -> 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::<i64>()
|
||||
.with_context(|| format!("parsing audio_delta_us `{}`", args[0]))?;
|
||||
let video_delta_us = args[1]
|
||||
.parse::<i64>()
|
||||
.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<I, S>(args: I) -> Result<Config>
|
||||
where
|
||||
@ -116,6 +165,11 @@ fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest>
|
||||
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<CalibrationRequest> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.17.12"
|
||||
version = "0.17.13"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.17.12"
|
||||
version = "0.17.13"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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 <audio_delta_us> <video_delta_us>"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user