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] Update contract tests to encode the new audio continuity policy.
|
||||||
- [x] Run focused client queue/probe contracts and package checks.
|
- [x] Run focused client queue/probe contracts and package checks.
|
||||||
- [x] Push clean semver `0.17.12` for installed client/server testing.
|
- [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]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.17.12"
|
version = "0.17.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.17.12"
|
version = "0.17.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.17.12"
|
version = "0.17.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.17.12"
|
version = "0.17.13"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use lesavka_common::lesavka::{
|
use lesavka_common::lesavka::{
|
||||||
CapturePowerCommand, HandshakeSet, SetCapturePowerRequest, relay_client::RelayClient,
|
CalibrationAction, CalibrationRequest, CalibrationState, CapturePowerCommand, HandshakeSet,
|
||||||
|
SetCapturePowerRequest, relay_client::RelayClient,
|
||||||
};
|
};
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use lesavka_common::lesavka::{Empty, handshake_client::HandshakeClient};
|
use lesavka_common::lesavka::{Empty, handshake_client::HandshakeClient};
|
||||||
@ -14,6 +15,11 @@ use lesavka_client::relay_transport;
|
|||||||
enum CommandKind {
|
enum CommandKind {
|
||||||
Status,
|
Status,
|
||||||
Version,
|
Version,
|
||||||
|
CalibrationStatus,
|
||||||
|
CalibrationAdjust,
|
||||||
|
CalibrationRestoreDefault,
|
||||||
|
CalibrationRestoreFactory,
|
||||||
|
CalibrationSaveDefault,
|
||||||
Auto,
|
Auto,
|
||||||
On,
|
On,
|
||||||
Off,
|
Off,
|
||||||
@ -30,6 +36,15 @@ impl CommandKind {
|
|||||||
match value {
|
match value {
|
||||||
"status" | "get" => Some(Self::Status),
|
"status" | "get" => Some(Self::Status),
|
||||||
"version" | "versions" => Some(Self::Version),
|
"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),
|
"auto" => Some(Self::Auto),
|
||||||
"on" | "force-on" => Some(Self::On),
|
"on" | "force-on" => Some(Self::On),
|
||||||
"off" | "force-off" => Some(Self::Off),
|
"off" | "force-off" => Some(Self::Off),
|
||||||
@ -47,6 +62,9 @@ impl CommandKind {
|
|||||||
struct Config {
|
struct Config {
|
||||||
server: String,
|
server: String,
|
||||||
command: CommandKind,
|
command: CommandKind,
|
||||||
|
audio_delta_us: i64,
|
||||||
|
video_delta_us: i64,
|
||||||
|
note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
@ -56,7 +74,7 @@ enum ParseOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> &'static str {
|
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>
|
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 args = args.into_iter().map(Into::into);
|
||||||
let mut server = "http://127.0.0.1:50051".to_string();
|
let mut server = "http://127.0.0.1:50051".to_string();
|
||||||
let mut command = None;
|
let mut command = None;
|
||||||
|
let mut command_args = Vec::new();
|
||||||
|
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
@ -86,16 +105,46 @@ where
|
|||||||
bail!("unknown command `{arg}`\n{}", usage());
|
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 {
|
Ok(ParseOutcome::Run(Config {
|
||||||
server,
|
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)]
|
#[cfg(test)]
|
||||||
fn parse_args_from<I, S>(args: I) -> Result<Config>
|
fn parse_args_from<I, S>(args: I) -> Result<Config>
|
||||||
where
|
where
|
||||||
@ -116,6 +165,11 @@ fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest>
|
|||||||
let (enabled, command) = match command {
|
let (enabled, command) = match command {
|
||||||
CommandKind::Status
|
CommandKind::Status
|
||||||
| CommandKind::Version
|
| CommandKind::Version
|
||||||
|
| CommandKind::CalibrationStatus
|
||||||
|
| CommandKind::CalibrationAdjust
|
||||||
|
| CommandKind::CalibrationRestoreDefault
|
||||||
|
| CommandKind::CalibrationRestoreFactory
|
||||||
|
| CommandKind::CalibrationSaveDefault
|
||||||
| CommandKind::RecoverUsb
|
| CommandKind::RecoverUsb
|
||||||
| CommandKind::RecoverUac
|
| CommandKind::RecoverUac
|
||||||
| CommandKind::RecoverUvc
|
| CommandKind::RecoverUvc
|
||||||
@ -221,6 +275,66 @@ fn print_upstream_sync(state: lesavka_common::lesavka::UpstreamSyncState) {
|
|||||||
println!("planner_detail={}", state.last_reason);
|
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))]
|
#[cfg(not(coverage))]
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
@ -247,6 +361,11 @@ async fn main() -> Result<()> {
|
|||||||
CommandKind::Off => "forcing capture power off",
|
CommandKind::Off => "forcing capture power off",
|
||||||
CommandKind::Status
|
CommandKind::Status
|
||||||
| CommandKind::Version
|
| CommandKind::Version
|
||||||
|
| CommandKind::CalibrationStatus
|
||||||
|
| CommandKind::CalibrationAdjust
|
||||||
|
| CommandKind::CalibrationRestoreDefault
|
||||||
|
| CommandKind::CalibrationRestoreFactory
|
||||||
|
| CommandKind::CalibrationSaveDefault
|
||||||
| CommandKind::RecoverUsb
|
| CommandKind::RecoverUsb
|
||||||
| CommandKind::RecoverUac
|
| CommandKind::RecoverUac
|
||||||
| CommandKind::RecoverUvc
|
| CommandKind::RecoverUvc
|
||||||
@ -262,6 +381,16 @@ async fn main() -> Result<()> {
|
|||||||
return Ok(());
|
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 {
|
let reply = match config.command {
|
||||||
CommandKind::Status => client
|
CommandKind::Status => client
|
||||||
.get_capture_power(Request::new(Empty {}))
|
.get_capture_power(Request::new(Empty {}))
|
||||||
@ -313,9 +442,22 @@ async fn main() -> Result<()> {
|
|||||||
print_upstream_sync(reply);
|
print_upstream_sync(reply);
|
||||||
return Ok(());
|
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 => {
|
CommandKind::Version | CommandKind::Auto | CommandKind::On | CommandKind::Off => {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
CommandKind::CalibrationAdjust
|
||||||
|
| CommandKind::CalibrationRestoreDefault
|
||||||
|
| CommandKind::CalibrationRestoreFactory
|
||||||
|
| CommandKind::CalibrationSaveDefault => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
print_state(reply);
|
print_state(reply);
|
||||||
@ -330,8 +472,8 @@ fn main() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
CapturePowerCommand, CommandKind, ParseOutcome, capture_power_request, parse_args_from,
|
CalibrationAction, CapturePowerCommand, CommandKind, Config, ParseOutcome,
|
||||||
parse_args_outcome_from,
|
calibration_request_for, capture_power_request, parse_args_from, parse_args_outcome_from,
|
||||||
};
|
};
|
||||||
use lesavka_common::lesavka::CapturePowerState;
|
use lesavka_common::lesavka::CapturePowerState;
|
||||||
|
|
||||||
@ -342,6 +484,26 @@ mod tests {
|
|||||||
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
|
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
|
||||||
assert_eq!(CommandKind::parse("version"), Some(CommandKind::Version));
|
assert_eq!(CommandKind::parse("version"), Some(CommandKind::Version));
|
||||||
assert_eq!(CommandKind::parse("versions"), 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!(
|
assert_eq!(
|
||||||
CommandKind::parse("upstream-sync"),
|
CommandKind::parse("upstream-sync"),
|
||||||
Some(CommandKind::UpstreamSync)
|
Some(CommandKind::UpstreamSync)
|
||||||
@ -373,6 +535,9 @@ mod tests {
|
|||||||
let config = parse_args_from(std::iter::empty::<&str>()).expect("default config");
|
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.server, "http://127.0.0.1:50051");
|
||||||
assert_eq!(config.command, CommandKind::Status);
|
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]
|
#[test]
|
||||||
@ -383,11 +548,31 @@ mod tests {
|
|||||||
assert_eq!(config.command, CommandKind::UpstreamSync);
|
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]
|
#[test]
|
||||||
fn parse_args_rejects_bad_inputs() {
|
fn parse_args_rejects_bad_inputs() {
|
||||||
assert!(parse_args_from(["--server"]).is_err());
|
assert!(parse_args_from(["--server"]).is_err());
|
||||||
assert!(parse_args_from(["nope"]).is_err());
|
assert!(parse_args_from(["nope"]).is_err());
|
||||||
assert!(parse_args_from(["status", "extra"]).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]
|
#[test]
|
||||||
@ -437,6 +622,11 @@ mod tests {
|
|||||||
|
|
||||||
assert!(capture_power_request(CommandKind::Status).is_none());
|
assert!(capture_power_request(CommandKind::Status).is_none());
|
||||||
assert!(capture_power_request(CommandKind::Version).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::RecoverUsb).is_none());
|
||||||
assert!(capture_power_request(CommandKind::RecoverUac).is_none());
|
assert!(capture_power_request(CommandKind::RecoverUac).is_none());
|
||||||
assert!(capture_power_request(CommandKind::RecoverUvc).is_none());
|
assert!(capture_power_request(CommandKind::RecoverUvc).is_none());
|
||||||
@ -456,4 +646,43 @@ mod tests {
|
|||||||
detail: "ready".to_string(),
|
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]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.17.12"
|
version = "0.17.13"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
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_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
|
||||||
PROBE_MARKER_TICK_PERIOD=${PROBE_MARKER_TICK_PERIOD:-5}
|
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}
|
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_PORT=${STIMULUS_PORT:-18444}
|
||||||
STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10}
|
STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10}
|
||||||
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"}
|
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"}
|
||||||
@ -221,6 +224,134 @@ print_upstream_sync_state() {
|
|||||||
done <<<"${sync_output}"
|
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() {
|
start_local_stimulus() {
|
||||||
echo "==> starting local A/V stimulus server"
|
echo "==> starting local A/V stimulus server"
|
||||||
python3 "${REPO_ROOT}/scripts/manual/local_av_stimulus.py" \
|
python3 "${REPO_ROOT}/scripts/manual/local_av_stimulus.py" \
|
||||||
@ -303,12 +434,15 @@ echo "==> prebuilding real client and analyzer"
|
|||||||
|
|
||||||
start_server_tunnel_if_needed
|
start_server_tunnel_if_needed
|
||||||
print_lesavka_versions
|
print_lesavka_versions
|
||||||
|
print_upstream_calibration_state "before mirrored run"
|
||||||
print_upstream_sync_state "before mirrored run"
|
print_upstream_sync_state "before mirrored run"
|
||||||
start_local_stimulus
|
start_local_stimulus
|
||||||
start_real_lesavka_client
|
start_real_lesavka_client
|
||||||
run_status=0
|
run_status=0
|
||||||
run_browser_capture_with_real_driver || run_status=$?
|
run_browser_capture_with_real_driver || run_status=$?
|
||||||
|
maybe_apply_probe_calibration
|
||||||
print_upstream_sync_state "after mirrored run"
|
print_upstream_sync_state "after mirrored run"
|
||||||
|
print_upstream_calibration_state "after mirrored run"
|
||||||
|
|
||||||
if ((run_status != 0)); then
|
if ((run_status != 0)); then
|
||||||
echo "==> mirrored probe failed"
|
echo "==> mirrored probe failed"
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.17.12"
|
version = "0.17.13"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -111,7 +111,17 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
|
|||||||
"combined version+revision",
|
"combined version+revision",
|
||||||
"run_status=0",
|
"run_status=0",
|
||||||
"run_browser_capture_with_real_driver || run_status=$?",
|
"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_sync_state \"after mirrored run\"",
|
||||||
|
"print_upstream_calibration_state \"after mirrored run\"",
|
||||||
"==> mirrored probe failed",
|
"==> mirrored probe failed",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@ -14,6 +14,22 @@ mod relayctl_binary {
|
|||||||
fn command_aliases_and_usage_are_stable() {
|
fn command_aliases_and_usage_are_stable() {
|
||||||
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
|
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
|
||||||
assert_eq!(CommandKind::parse("get"), 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("auto"), Some(CommandKind::Auto));
|
||||||
assert_eq!(CommandKind::parse("on"), Some(CommandKind::On));
|
assert_eq!(CommandKind::parse("on"), Some(CommandKind::On));
|
||||||
assert_eq!(CommandKind::parse("force-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("lesavka-relayctl"));
|
||||||
assert!(usage().contains("recover-uac"));
|
assert!(usage().contains("recover-uac"));
|
||||||
assert!(usage().contains("recover-uvc"));
|
assert!(usage().contains("recover-uvc"));
|
||||||
|
assert!(usage().contains("calibrate <audio_delta_us> <video_delta_us>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user