fix: bake direct UVC sync center

This commit is contained in:
Brad Stein 2026-05-03 20:28:04 -03:00
parent ffa4c44af1
commit 1ec6baa06c
10 changed files with 140 additions and 32 deletions

View File

@ -114,6 +114,12 @@ path.
- [x] Measure freshness clock alignment from the server host to Tethys directly - [x] Measure freshness clock alignment from the server host to Tethys directly
instead of routing both clock samples through the client laptop. instead of routing both clock samples through the client laptop.
- [x] Include clock uncertainty as a margin in freshness pass/fail decisions. - [x] Include clock uncertainty as a margin in freshness pass/fail decisions.
- [x] Recenter the normal MJPEG/UVC output-path video baseline to `+170ms`
from repeated direct UVC/UAC target estimates, and migrate untouched
`+1090ms` factory/env baselines down to that center.
- [x] Split freshness reporting into fixed sync delay, last-hop device
overhead, and total RC target event age so freshness work cannot hide
the sync cost.
- [ ] Keep UI/profile controls authoritative for UVC output profiles beyond - [ ] Keep UI/profile controls authoritative for UVC output profiles beyond
`640x480@20`; validate `1280x720@30` and `1920x1080@20/30` after sync is `640x480@20`; validate `1280x720@30` and `1920x1080@20/30` after sync is
locked. locked.

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.19.8" version = "0.19.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.19.8" version = "0.19.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.19.8" version = "0.19.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.19.8" version = "0.19.9"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.19.8" version = "0.19.9"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -15,13 +15,14 @@ INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}
LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki} LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki}
LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz} LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz}
DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0 DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0
DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=1090000 DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=170000
LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000 LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000
PREVIOUS_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000 PREVIOUS_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=720000
PREVIOUS_TUNED_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=1260000 PREVIOUS_TUNED_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=1260000
PREVIOUS_ZERO_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=0 PREVIOUS_ZERO_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=0
PREVIOUS_DELAYED_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=350000 PREVIOUS_DELAYED_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=350000
PREVIOUS_BROWSER_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=130000 PREVIOUS_BROWSER_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=130000
PREVIOUS_OVERSHOT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=1090000
resolve_upstream_audio_playout_offset_us() { resolve_upstream_audio_playout_offset_us() {
if [[ -n ${LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-} ]]; then if [[ -n ${LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:-} ]]; then
@ -50,8 +51,8 @@ resolve_upstream_video_playout_offset_us() {
return 0 return 0
fi fi
if [[ ${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_ZERO_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" || ${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_DELAYED_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" || ${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_BROWSER_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" ]]; then if [[ ${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_ZERO_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" || ${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_DELAYED_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" || ${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_BROWSER_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" || ${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-} == "$PREVIOUS_OVERSHOT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" ]]; then
echo "⚠️ migrating stale upstream video playout offset to the 0.17 browser-visible MJPEG/UVC sync baseline." >&2 echo "⚠️ migrating stale upstream video playout offset to the direct UVC/UAC MJPEG sync center." >&2
echo " Use LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US to intentionally keep an older value." >&2 echo " Use LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US to intentionally keep an older value." >&2
printf '%s\n' "$DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US" printf '%s\n' "$DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US"
return 0 return 0

View File

@ -931,6 +931,17 @@ def stats(rows, key):
} }
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"]:
value = shifted.get(key)
shifted[key] = value + delta_ms if value is not None else None
shifted["intentional_delay_ms"] = delta_ms
return shifted
def fit_linear(rows, key): def fit_linear(rows, key):
points = [(row["event_time_s"], row[key]) for row in rows if row.get(key) is not None] points = [(row["event_time_s"], row[key]) for row in rows if row.get(key) is not None]
if len(points) < 2: if len(points) < 2:
@ -992,6 +1003,8 @@ theia_to_tethys_offset_ns = (
else None else None
) )
clock_uncertainty_ms = as_float(clock_alignment.get("uncertainty_ms"), 0.0) clock_uncertainty_ms = as_float(clock_alignment.get("uncertainty_ms"), 0.0)
audio_delay_ms = as_float(timeline.get("audio_delay_us"), 0.0) / 1000.0
video_delay_ms = as_float(timeline.get("video_delay_us"), 0.0) / 1000.0
joined = [] joined = []
for observed in report.get("paired_events", []): for observed in report.get("paired_events", []):
@ -1041,6 +1054,12 @@ for observed in report.get("paired_events", []):
if tethys_audio_unix_ns is not None and corrected_server_audio_push_unix_ns is not None if tethys_audio_unix_ns is not None and corrected_server_audio_push_unix_ns is not None
else None else None
) )
video_event_age_ms = (
video_freshness_ms + video_delay_ms if video_freshness_ms is not None else None
)
audio_event_age_ms = (
audio_freshness_ms + audio_delay_ms if audio_freshness_ms is not None else None
)
residual_path_skew_ms = ( residual_path_skew_ms = (
observed_skew_ms - server_feed_delta_ms observed_skew_ms - server_feed_delta_ms
if observed_skew_ms is not None and server_feed_delta_ms is not None if observed_skew_ms is not None and server_feed_delta_ms is not None
@ -1066,6 +1085,8 @@ for observed in report.get("paired_events", []):
"server_audio_push_s": server_audio_push_s, "server_audio_push_s": server_audio_push_s,
"video_freshness_ms": video_freshness_ms, "video_freshness_ms": video_freshness_ms,
"audio_freshness_ms": audio_freshness_ms, "audio_freshness_ms": audio_freshness_ms,
"video_event_age_ms": video_event_age_ms,
"audio_event_age_ms": audio_event_age_ms,
"server_feed_delta_ms": server_feed_delta_ms, "server_feed_delta_ms": server_feed_delta_ms,
"residual_path_skew_ms": residual_path_skew_ms, "residual_path_skew_ms": residual_path_skew_ms,
"confidence": finite(observed.get("confidence")), "confidence": finite(observed.get("confidence")),
@ -1081,8 +1102,12 @@ server_model = fit_linear(joined, "server_feed_delta_ms")
residual_model = fit_linear(joined, "residual_path_skew_ms") residual_model = fit_linear(joined, "residual_path_skew_ms")
video_freshness_model = fit_linear(joined, "video_freshness_ms") video_freshness_model = fit_linear(joined, "video_freshness_ms")
audio_freshness_model = fit_linear(joined, "audio_freshness_ms") audio_freshness_model = fit_linear(joined, "audio_freshness_ms")
video_event_age_model = fit_linear(joined, "video_event_age_ms")
audio_event_age_model = fit_linear(joined, "audio_event_age_ms")
video_freshness_stats = stats(joined, "video_freshness_ms") video_freshness_stats = stats(joined, "video_freshness_ms")
audio_freshness_stats = stats(joined, "audio_freshness_ms") 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") server_observed_correlation = correlation(joined, "server_feed_delta_ms", "observed_skew_ms")
observed_drift = observed_model.get("drift_ms", 0.0) observed_drift = observed_model.get("drift_ms", 0.0)
@ -1112,17 +1137,31 @@ freshness_p95_values = [
] ]
if value is not None if value is not None
] ]
event_age_p95_values = [
value
for value in [
video_event_age_stats.get("p95_ms"),
audio_event_age_stats.get("p95_ms"),
]
if value is not None
]
freshness_drift_values = [ freshness_drift_values = [
abs(value) abs(value)
for value in [ for value in [
video_freshness_model.get("drift_ms"), video_event_age_model.get("drift_ms"),
audio_freshness_model.get("drift_ms"), audio_event_age_model.get("drift_ms"),
] ]
if value is not None and math.isfinite(value) if value is not None and math.isfinite(value)
] ]
freshness_worst_p95_ms = max(freshness_p95_values) if freshness_p95_values else None freshness_worst_p95_ms = max(freshness_p95_values) if freshness_p95_values else None
freshness_worst_event_p95_ms = max(event_age_p95_values) if event_age_p95_values else None
freshness_worst_drift_ms = max(freshness_drift_values) if freshness_drift_values else None freshness_worst_drift_ms = max(freshness_drift_values) if freshness_drift_values else None
if not freshness_p95_values: freshness_worst_event_with_uncertainty_ms = (
freshness_worst_event_p95_ms + clock_uncertainty_ms
if freshness_worst_event_p95_ms is not None
else None
)
if not event_age_p95_values:
freshness_status = "unknown" freshness_status = "unknown"
freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available" freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available"
elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms: elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms:
@ -1131,27 +1170,27 @@ elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertain
f"clock uncertainty {clock_uncertainty_ms:.1f} ms exceeds " f"clock uncertainty {clock_uncertainty_ms:.1f} ms exceeds "
f"{max_clock_uncertainty_ms:.1f} ms freshness measurement limit" f"{max_clock_uncertainty_ms:.1f} ms freshness measurement limit"
) )
elif freshness_worst_p95_ms < -clock_uncertainty_ms: elif freshness_worst_event_p95_ms < -clock_uncertainty_ms:
freshness_status = "invalid" freshness_status = "invalid"
freshness_reason = ( freshness_reason = (
f"freshness was negative beyond clock uncertainty: " f"freshness was negative beyond clock uncertainty: "
f"worst p95 {freshness_worst_p95_ms:.1f} ms, uncertainty " f"worst RC event-age p95 {freshness_worst_event_p95_ms:.1f} ms, uncertainty "
f"{clock_uncertainty_ms:.1f} ms" f"{clock_uncertainty_ms:.1f} ms"
) )
elif (freshness_worst_p95_ms + clock_uncertainty_ms) <= max_freshness_age_ms and ( elif freshness_worst_event_with_uncertainty_ms <= max_freshness_age_ms and (
freshness_worst_drift_ms is None or freshness_worst_drift_ms <= max_freshness_drift_ms freshness_worst_drift_ms is None or freshness_worst_drift_ms <= max_freshness_drift_ms
): ):
freshness_status = "pass" freshness_status = "pass"
freshness_reason = ( freshness_reason = (
f"worst p95 freshness {freshness_worst_p95_ms:.1f} ms + clock uncertainty " f"worst RC event-age p95 {freshness_worst_event_p95_ms:.1f} ms + clock uncertainty "
f"{clock_uncertainty_ms:.1f} ms <= {max_freshness_age_ms:.1f} ms and worst freshness drift " f"{clock_uncertainty_ms:.1f} ms <= {max_freshness_age_ms:.1f} ms and worst freshness drift "
f"{(freshness_worst_drift_ms or 0.0):.1f} ms <= {max_freshness_drift_ms:.1f} ms" f"{(freshness_worst_drift_ms or 0.0):.1f} ms <= {max_freshness_drift_ms:.1f} ms"
) )
else: else:
freshness_status = "fail" freshness_status = "fail"
freshness_reason = ( freshness_reason = (
f"worst p95 freshness " f"worst RC event-age p95 "
f"{freshness_worst_p95_ms if freshness_worst_p95_ms is not None else 0.0:.1f} ms " f"{freshness_worst_event_p95_ms if freshness_worst_event_p95_ms is not None else 0.0:.1f} ms "
f"+ clock uncertainty {clock_uncertainty_ms:.1f} ms " f"+ clock uncertainty {clock_uncertainty_ms:.1f} ms "
f"(limit {max_freshness_age_ms:.1f} ms), worst freshness drift " f"(limit {max_freshness_age_ms:.1f} ms), worst freshness drift "
f"{freshness_worst_drift_ms if freshness_worst_drift_ms is not None else 0.0:.1f} ms " f"{freshness_worst_drift_ms if freshness_worst_drift_ms is not None else 0.0:.1f} ms "
@ -1178,12 +1217,21 @@ artifact = {
"max_age_limit_ms": max_freshness_age_ms, "max_age_limit_ms": max_freshness_age_ms,
"max_drift_limit_ms": max_freshness_drift_ms, "max_drift_limit_ms": max_freshness_drift_ms,
"max_clock_uncertainty_ms": max_clock_uncertainty_ms, "max_clock_uncertainty_ms": max_clock_uncertainty_ms,
"worst_p95_freshness_ms": freshness_worst_p95_ms, "intentional_audio_delay_ms": audio_delay_ms,
"intentional_video_delay_ms": video_delay_ms,
"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,
"worst_p95_freshness_ms": freshness_worst_event_p95_ms,
"worst_freshness_drift_ms": freshness_worst_drift_ms, "worst_freshness_drift_ms": freshness_worst_drift_ms,
"video_freshness_stats": video_freshness_stats, "video_freshness_stats": video_freshness_stats,
"audio_freshness_stats": audio_freshness_stats, "audio_freshness_stats": audio_freshness_stats,
"video_event_age_stats": video_event_age_stats,
"audio_event_age_stats": audio_event_age_stats,
"video_freshness_model": video_freshness_model, "video_freshness_model": video_freshness_model,
"audio_freshness_model": audio_freshness_model, "audio_freshness_model": audio_freshness_model,
"video_event_age_model": video_event_age_model,
"audio_event_age_model": audio_event_age_model,
}, },
"server_observed_correlation": server_observed_correlation, "server_observed_correlation": server_observed_correlation,
"server_drift_share_of_observed": server_share, "server_drift_share_of_observed": server_share,
@ -1222,6 +1270,8 @@ with pathlib.Path(output_csv_path).open("w", newline="", encoding="utf-8") as ha
"tethys_audio_unix_ns", "tethys_audio_unix_ns",
"video_freshness_ms", "video_freshness_ms",
"audio_freshness_ms", "audio_freshness_ms",
"video_event_age_ms",
"audio_event_age_ms",
"server_feed_delta_ms", "server_feed_delta_ms",
"residual_path_skew_ms", "residual_path_skew_ms",
"server_video_feed_monotonic_us", "server_video_feed_monotonic_us",
@ -1248,10 +1298,12 @@ lines = [
"- scope: clock-corrected server feed to Tethys capture event from the same paired signatures", "- scope: clock-corrected server feed to Tethys capture event from the same paired signatures",
f"- freshness status: {freshness_status} ({freshness_reason})", f"- freshness status: {freshness_status} ({freshness_reason})",
f"- clock uncertainty: +/-{clock_uncertainty_ms:.1f} ms", f"- clock uncertainty: +/-{clock_uncertainty_ms:.1f} ms",
f"- video freshness: first {video_freshness_stats.get('first_ms') or 0.0:.1f} ms, median {video_freshness_stats.get('median_ms') or 0.0:.1f} ms, p95 {video_freshness_stats.get('p95_ms') or 0.0:.1f} ms, max {video_freshness_stats.get('max_ms') or 0.0:.1f} ms", f"- intentional sync delays: audio {audio_delay_ms:+.1f} ms, video {video_delay_ms:+.1f} ms",
f"- audio freshness: first {audio_freshness_stats.get('first_ms') or 0.0:.1f} ms, median {audio_freshness_stats.get('median_ms') or 0.0:.1f} ms, p95 {audio_freshness_stats.get('p95_ms') or 0.0:.1f} ms, max {audio_freshness_stats.get('max_ms') or 0.0:.1f} ms", f"- device path overhead: video median {video_freshness_stats.get('median_ms') or 0.0:.1f} ms / p95 {video_freshness_stats.get('p95_ms') or 0.0:.1f} ms / max {video_freshness_stats.get('max_ms') or 0.0:.1f} ms; audio median {audio_freshness_stats.get('median_ms') or 0.0:.1f} ms / p95 {audio_freshness_stats.get('p95_ms') or 0.0:.1f} ms / max {audio_freshness_stats.get('max_ms') or 0.0:.1f} ms",
f"- video freshness drift: {video_freshness_model.get('drift_ms', 0.0):+.1f} ms over paired events ({video_freshness_model.get('slope_ms_per_s', 0.0):+.3f} ms/s)", f"- RC target event age: video median {video_event_age_stats.get('median_ms') or 0.0:.1f} ms / p95 {video_event_age_stats.get('p95_ms') or 0.0:.1f} ms / max {video_event_age_stats.get('max_ms') or 0.0:.1f} ms; audio median {audio_event_age_stats.get('median_ms') or 0.0:.1f} ms / p95 {audio_event_age_stats.get('p95_ms') or 0.0:.1f} ms / max {audio_event_age_stats.get('max_ms') or 0.0:.1f} ms",
f"- audio freshness drift: {audio_freshness_model.get('drift_ms', 0.0):+.1f} ms over paired events ({audio_freshness_model.get('slope_ms_per_s', 0.0):+.3f} ms/s)", f"- freshness budget: worst RC event-age p95 {(freshness_worst_event_p95_ms or 0.0):.1f} ms + clock uncertainty {clock_uncertainty_ms:.1f} ms = {(freshness_worst_event_with_uncertainty_ms or 0.0):.1f} ms vs limit {max_freshness_age_ms:.1f} ms",
f"- video event-age drift: {video_event_age_model.get('drift_ms', 0.0):+.1f} ms over paired events ({video_event_age_model.get('slope_ms_per_s', 0.0):+.3f} ms/s)",
f"- audio event-age drift: {audio_event_age_model.get('drift_ms', 0.0):+.1f} ms over paired events ({audio_event_age_model.get('slope_ms_per_s', 0.0):+.3f} ms/s)",
] ]
summary = "\n".join(lines) + "\n" summary = "\n".join(lines) + "\n"
pathlib.Path(output_txt_path).write_text(summary) pathlib.Path(output_txt_path).write_text(summary)

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.19.8" version = "0.19.9"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -10,18 +10,17 @@ use lesavka_common::lesavka::{
use crate::upstream_media_runtime::UpstreamMediaRuntime; use crate::upstream_media_runtime::UpstreamMediaRuntime;
pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 0; pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 0;
// 0.17.10's mirrored browser probe showed the remaining sync error lives // Direct UVC/UAC output-delay probes against the lab RC target put the
// after server enqueue: UAC audio becomes browser-visible on Tethys roughly // server-to-target sync center near 170ms for MJPEG/UVC video. This is an
// 950-970ms after the matching UVC frame. Delay MJPEG/UVC video by that // output-path compensation, not a freshness buffer.
// measured egress delta; the planner treats this as intentional output-path pub const FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 170_000;
// sync compensation rather than stale-media drift.
pub const FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 1_090_000;
const LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000; const LEGACY_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000;
const PREVIOUS_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 720_000; const PREVIOUS_FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = 720_000;
const PREVIOUS_TUNED_MJPEG_AUDIO_OFFSET_US: i64 = 1_260_000; const PREVIOUS_TUNED_MJPEG_AUDIO_OFFSET_US: i64 = 1_260_000;
const PREVIOUS_FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 0; const PREVIOUS_FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 0;
const PREVIOUS_DELAYED_FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 350_000; const PREVIOUS_DELAYED_FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 350_000;
const PREVIOUS_BROWSER_FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 130_000; const PREVIOUS_BROWSER_FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 130_000;
const PREVIOUS_OVERSHOT_FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 1_090_000;
const PROFILE: &str = "mjpeg"; const PROFILE: &str = "mjpeg";
const FACTORY_CONFIDENCE: &str = "factory"; const FACTORY_CONFIDENCE: &str = "factory";
const PREVIOUS_OFFSET_LIMIT_US: i64 = 500_000; const PREVIOUS_OFFSET_LIMIT_US: i64 = 500_000;
@ -296,6 +295,7 @@ fn migrate_legacy_snapshot(mut state: CalibrationSnapshot) -> CalibrationSnapsho
PREVIOUS_FACTORY_MJPEG_VIDEO_OFFSET_US PREVIOUS_FACTORY_MJPEG_VIDEO_OFFSET_US
| PREVIOUS_DELAYED_FACTORY_MJPEG_VIDEO_OFFSET_US | PREVIOUS_DELAYED_FACTORY_MJPEG_VIDEO_OFFSET_US
| PREVIOUS_BROWSER_FACTORY_MJPEG_VIDEO_OFFSET_US | PREVIOUS_BROWSER_FACTORY_MJPEG_VIDEO_OFFSET_US
| PREVIOUS_OVERSHOT_FACTORY_MJPEG_VIDEO_OFFSET_US
) && state.active_video_offset_us == state.default_video_offset_us; ) && state.active_video_offset_us == state.default_video_offset_us;
if state.profile == PROFILE if state.profile == PROFILE
&& source_allows_migration && source_allows_migration
@ -535,7 +535,42 @@ mod tests {
runtime.playout_offsets(), runtime.playout_offsets(),
(FACTORY_MJPEG_VIDEO_OFFSET_US, 0) (FACTORY_MJPEG_VIDEO_OFFSET_US, 0)
); );
assert!(state.detail.contains("to audio +0.0ms/video +1090.0ms")); assert!(state.detail.contains("to audio +0.0ms/video +170.0ms"));
});
}
#[test]
fn load_migrates_overshot_video_factory_mjpeg_baseline() {
let file = NamedTempFile::new().expect("temp calibration file");
std::fs::write(
file.path(),
r#"
profile="mjpeg"
default_audio_offset_us=0
default_video_offset_us=1090000
active_audio_offset_us=0
active_video_offset_us=1090000
source="factory"
confidence="factory"
detail="loaded upstream A/V calibration defaults"
"#,
)
.expect("overshot video calibration seed");
let path = file.path().to_string_lossy().to_string();
temp_env::with_var("LESAVKA_CALIBRATION_PATH", Some(path.as_str()), || {
let runtime = Arc::new(UpstreamMediaRuntime::new());
let store = CalibrationStore::load(runtime.clone());
let state = store.current();
assert_eq!(state.active_audio_offset_us, 0);
assert_eq!(state.default_audio_offset_us, 0);
assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US);
assert_eq!(state.default_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US);
assert_eq!(state.source, "factory");
assert_eq!(
runtime.playout_offsets(),
(FACTORY_MJPEG_VIDEO_OFFSET_US, 0)
);
assert!(state.detail.contains("from audio +0.0ms/video +1090.0ms"));
}); });
} }

View File

@ -79,8 +79,17 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
"dominant_layer", "dominant_layer",
"freshness status", "freshness status",
"clock-corrected server feed to Tethys capture event", "clock-corrected server feed to Tethys capture event",
"intentional sync delays",
"device path overhead",
"RC target event age",
"freshness budget",
"video_freshness_ms", "video_freshness_ms",
"audio_freshness_ms", "audio_freshness_ms",
"video_event_age_ms",
"audio_event_age_ms",
"intentional_video_delay_ms",
"video_event_age_stats",
"worst_event_age_with_uncertainty_ms",
"video_delay_function_candidate", "video_delay_function_candidate",
"source\": \"direct-uvc-uac-output-probe\"", "source\": \"direct-uvc-uac-output-probe\"",
"scope\": \"server-output-static-baseline\"", "scope\": \"server-output-static-baseline\"",

View File

@ -56,7 +56,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS:-1000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS:-1000}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_STARTUP_TIMEOUT_MS:-60000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_STARTUP_TIMEOUT_MS:-60000}"));
assert!(SERVER_INSTALL.contains("DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0")); assert!(SERVER_INSTALL.contains("DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0"));
assert!(SERVER_INSTALL.contains("DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=1090000")); assert!(SERVER_INSTALL.contains("DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=170000"));
assert!( assert!(
SERVER_INSTALL.contains("resolve_upstream_video_playout_offset_us"), SERVER_INSTALL.contains("resolve_upstream_video_playout_offset_us"),
"video offset should be resolved through stale-baseline migration logic" "video offset should be resolved through stale-baseline migration logic"
@ -72,6 +72,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!( assert!(
SERVER_INSTALL.contains("PREVIOUS_BROWSER_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=130000") SERVER_INSTALL.contains("PREVIOUS_BROWSER_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=130000")
); );
assert!(
SERVER_INSTALL.contains("PREVIOUS_OVERSHOT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=1090000")
);
assert!( assert!(
SERVER_INSTALL.contains("LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US"), SERVER_INSTALL.contains("LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US"),
"install-specific offset override should bypass stale ambient runtime env" "install-specific offset override should bypass stale ambient runtime env"
@ -85,7 +88,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
"installer should not preserve old MJPEG/UVC sync baselines accidentally" "installer should not preserve old MJPEG/UVC sync baselines accidentally"
); );
assert!( assert!(
SERVER_INSTALL.contains("migrating stale upstream video playout offset to the 0.17 browser-visible MJPEG/UVC sync baseline"), SERVER_INSTALL.contains(
"migrating stale upstream video playout offset to the direct UVC/UAC MJPEG sync center"
),
"installer should not preserve old video delay baselines accidentally" "installer should not preserve old video delay baselines accidentally"
); );
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}"));