2448 lines
100 KiB
Bash
Executable File
2448 lines
100 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/manual/run_server_to_rc_mode_matrix.sh
|
|
# Manual: validate server-generated UVC/UAC output against the RC target across
|
|
# the UVC modes the UI advertises. This is still a hardware-in-the-loop probe:
|
|
# it captures the real Tethys UVC/UAC endpoints and summarizes sync,
|
|
# freshness, and smoothness for each mode.
|
|
#
|
|
# Reconfigure mode is intentionally a fast runtime path: it updates the remote
|
|
# Lesavka env files and cycles the UVC gadget, but it does not rebuild or
|
|
# reinstall the server binary for each mode.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
|
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)"
|
|
|
|
if (( EUID == 0 )); then
|
|
printf 'Do not run this matrix with local sudo.\n' >&2
|
|
printf 'Run it as your normal workstation user; the script will request sudo on the remote server host only when reconfiguring UVC.\n' >&2
|
|
exit 64
|
|
fi
|
|
|
|
TETHYS_HOST=${TETHYS_HOST:-tethys}
|
|
LESAVKA_SERVER_HOST=${LESAVKA_SERVER_HOST:-theia}
|
|
LESAVKA_SERVER_CONNECT_HOST=${LESAVKA_SERVER_CONNECT_HOST:-38.28.125.112}
|
|
LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR:-auto}
|
|
LESAVKA_SERVER_SCHEME=${LESAVKA_SERVER_SCHEME:-https}
|
|
LESAVKA_TLS_DOMAIN=${LESAVKA_TLS_DOMAIN:-lesavka-server}
|
|
LESAVKA_SERVER_REPO=${LESAVKA_SERVER_REPO:-auto}
|
|
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
|
|
|
LESAVKA_SERVER_RC_CORE_WEBCAM_MODES=${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES:-1280x720@20,1280x720@30,1920x1080@20,1920x1080@30}
|
|
LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES}}
|
|
LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US:-${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}}
|
|
LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-135090}
|
|
LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US:-1280x720@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1280x720@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}}
|
|
LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-1280x720@20=162659,1280x720@30=135090,1920x1080@20=160045,1920x1080@30=127952}
|
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES=${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES:-1280x720,1920x1080}
|
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS=${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS:-20,30}
|
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX:-Logitech|BRIO|C9[0-9]+|HD UVC WebCam|USB2[.]0 HD|Integrated Camera|Webcam|Camera}
|
|
LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX:-Lesavka|UGREEN|MACROSILICON|Composite|Capture}
|
|
LESAVKA_SERVER_RC_MODE_SOURCE=${LESAVKA_SERVER_RC_MODE_SOURCE:-configured}
|
|
LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0}
|
|
LESAVKA_SERVER_RC_RECONFIGURE_REF=${LESAVKA_SERVER_RC_RECONFIGURE_REF:-master}
|
|
LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0}
|
|
LESAVKA_SERVER_RC_RECONFIGURE_COMMAND=${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND:-}
|
|
LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY:-runtime}
|
|
LESAVKA_SERVER_RC_RECONFIGURE_CODEC=${LESAVKA_SERVER_RC_RECONFIGURE_CODEC:-mjpeg}
|
|
LESAVKA_SERVER_RC_ALLOW_GADGET_RESET=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET:-1}
|
|
LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD=${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD:-1}
|
|
LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS=${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS:-4}
|
|
LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE=${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE:-0}
|
|
LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY:-1}
|
|
LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD=${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD:-}
|
|
LESAVKA_SERVER_RC_START_DELAY_SECONDS=${LESAVKA_SERVER_RC_START_DELAY_SECONDS:-0}
|
|
LESAVKA_SERVER_RC_WAIT_TETHYS_READY=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY:-1}
|
|
LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS:-60}
|
|
LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS:-6}
|
|
LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS:-3}
|
|
LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1}
|
|
LESAVKA_SERVER_RC_CONTINUE_ON_FAIL=${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL:-1}
|
|
LESAVKA_SERVER_RC_TUNE_DELAYS=${LESAVKA_SERVER_RC_TUNE_DELAYS:-1}
|
|
LESAVKA_SERVER_RC_TUNE_CONFIRM=${LESAVKA_SERVER_RC_TUNE_CONFIRM:-1}
|
|
LESAVKA_SERVER_RC_TUNE_MIN_PAIRS=${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS:-13}
|
|
LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS=${LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS:-1000}
|
|
LESAVKA_SERVER_RC_TUNE_MAX_DRIFT_MS=${LESAVKA_SERVER_RC_TUNE_MAX_DRIFT_MS:-80}
|
|
LESAVKA_SERVER_RC_TUNE_MAX_STEP_US=${LESAVKA_SERVER_RC_TUNE_MAX_STEP_US:-500000}
|
|
LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US=${LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US:-5000}
|
|
LESAVKA_SERVER_RC_REPEAT_COUNT=${LESAVKA_SERVER_RC_REPEAT_COUNT:-1}
|
|
LESAVKA_SERVER_RC_VERBOSE_PROBES=${LESAVKA_SERVER_RC_VERBOSE_PROBES:-1}
|
|
LESAVKA_SERVER_RC_STATIC_MIN_RUNS=${LESAVKA_SERVER_RC_STATIC_MIN_RUNS:-3}
|
|
LESAVKA_SERVER_RC_STATIC_MAX_SPREAD_US=${LESAVKA_SERVER_RC_STATIC_MAX_SPREAD_US:-30000}
|
|
LESAVKA_SERVER_RC_STATIC_MAX_P95_SKEW_MS=${LESAVKA_SERVER_RC_STATIC_MAX_P95_SKEW_MS:-35}
|
|
LESAVKA_SERVER_RC_STATIC_MAX_MEDIAN_SKEW_MS=${LESAVKA_SERVER_RC_STATIC_MAX_MEDIAN_SKEW_MS:-20}
|
|
LESAVKA_SERVER_RC_STATIC_REQUIRE_FRESHNESS=${LESAVKA_SERVER_RC_STATIC_REQUIRE_FRESHNESS:-1}
|
|
LESAVKA_SERVER_RC_STATIC_REQUIRE_SMOOTHNESS=${LESAVKA_SERVER_RC_STATIC_REQUIRE_SMOOTHNESS:-0}
|
|
|
|
LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}
|
|
LESAVKA_SERVER_RC_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_DRIFT_MS:-100}
|
|
LESAVKA_SERVER_RC_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}
|
|
LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS=${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS:-${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS}}
|
|
LESAVKA_SERVER_RC_REQUIRE_FRESHNESS_PASS=${LESAVKA_SERVER_RC_REQUIRE_FRESHNESS_PASS:-1}
|
|
LESAVKA_SERVER_RC_REQUIRE_SYNC_PASS=${LESAVKA_SERVER_RC_REQUIRE_SYNC_PASS:-1}
|
|
LESAVKA_SERVER_RC_MIN_CODED_PAIRS=${LESAVKA_SERVER_RC_MIN_CODED_PAIRS:-${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}}
|
|
LESAVKA_SERVER_RC_REQUIRE_ALL_CODED_PAIRS=${LESAVKA_SERVER_RC_REQUIRE_ALL_CODED_PAIRS:-0}
|
|
LESAVKA_SERVER_RC_REQUIRE_SMOOTHNESS_PASS=${LESAVKA_SERVER_RC_REQUIRE_SMOOTHNESS_PASS:-0}
|
|
LESAVKA_SERVER_RC_SIGNAL_READY=${LESAVKA_SERVER_RC_SIGNAL_READY:-1}
|
|
LESAVKA_SERVER_RC_SIGNAL_READY_MODE=${LESAVKA_SERVER_RC_SIGNAL_READY_MODE:-conditioned_capture}
|
|
LESAVKA_SERVER_RC_SIGNAL_READY_MIN_PAIRS=${LESAVKA_SERVER_RC_SIGNAL_READY_MIN_PAIRS:-3}
|
|
LESAVKA_SERVER_RC_SIGNAL_READY_DURATION_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_READY_DURATION_SECONDS:-12}
|
|
LESAVKA_SERVER_RC_SIGNAL_READY_WARMUP_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_READY_WARMUP_SECONDS:-1}
|
|
LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS=${LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS:-4}
|
|
LESAVKA_SERVER_RC_SIGNAL_READY_RETRY_DELAY_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_READY_RETRY_DELAY_SECONDS:-5}
|
|
LESAVKA_SERVER_RC_SIGNAL_CONDITION_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_SECONDS:-12}
|
|
LESAVKA_SERVER_RC_SIGNAL_CONDITION_WARMUP_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_WARMUP_SECONDS:-1}
|
|
LESAVKA_SERVER_RC_SIGNAL_CONDITION_GAP_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_GAP_SECONDS:-1}
|
|
LESAVKA_SERVER_RC_SIGNAL_CONDITION_EVENT_WIDTH_CODES=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_EVENT_WIDTH_CODES:-${PROBE_EVENT_WIDTH_CODES:-1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}}
|
|
LESAVKA_SERVER_RC_ANALYSIS_TIMELINE_WINDOW=${LESAVKA_SERVER_RC_ANALYSIS_TIMELINE_WINDOW:-auto}
|
|
|
|
LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS=${LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS:-0}
|
|
LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS=${LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS:-0}
|
|
LESAVKA_SERVER_RC_MAX_VIDEO_P95_JITTER_MS=${LESAVKA_SERVER_RC_MAX_VIDEO_P95_JITTER_MS:-1}
|
|
LESAVKA_SERVER_RC_MAX_AUDIO_P95_JITTER_MS=${LESAVKA_SERVER_RC_MAX_AUDIO_P95_JITTER_MS:-1}
|
|
LESAVKA_SERVER_RC_MAX_VIDEO_MISSING_FRAMES=${LESAVKA_SERVER_RC_MAX_VIDEO_MISSING_FRAMES:-12}
|
|
LESAVKA_SERVER_RC_MAX_VIDEO_UNDECODABLE_FRAMES=${LESAVKA_SERVER_RC_MAX_VIDEO_UNDECODABLE_FRAMES:-12}
|
|
LESAVKA_SERVER_RC_MAX_VIDEO_DUPLICATE_FRAMES=${LESAVKA_SERVER_RC_MAX_VIDEO_DUPLICATE_FRAMES:-5}
|
|
LESAVKA_SERVER_RC_MAX_AUDIO_LOW_RMS_WINDOWS=${LESAVKA_SERVER_RC_MAX_AUDIO_LOW_RMS_WINDOWS:-2}
|
|
|
|
PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-20}
|
|
PROBE_WARMUP_SECONDS=${PROBE_WARMUP_SECONDS:-4}
|
|
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}
|
|
REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
|
|
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-cfr}
|
|
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
|
|
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
|
|
REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK:-0}
|
|
REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS:-1}
|
|
LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US=${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}
|
|
|
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
|
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-/tmp}
|
|
MATRIX_REPORT_DIR=${MATRIX_REPORT_DIR:-"${LOCAL_OUTPUT_DIR%/}/lesavka-server-rc-mode-matrix-${STAMP}"}
|
|
MATRIX_SUMMARY_JSON="${MATRIX_REPORT_DIR}/mode-matrix-summary.json"
|
|
MATRIX_SUMMARY_CSV="${MATRIX_REPORT_DIR}/mode-matrix-summary.csv"
|
|
MATRIX_SUMMARY_TXT="${MATRIX_REPORT_DIR}/mode-matrix-summary.txt"
|
|
MATRIX_DELAY_JSON="${MATRIX_REPORT_DIR}/mode-delay-recommendations.json"
|
|
MATRIX_DELAY_ENV="${MATRIX_REPORT_DIR}/mode-delay-recommendations.env"
|
|
MATRIX_STATIC_JSON="${MATRIX_REPORT_DIR}/mode-static-calibration.json"
|
|
MATRIX_STATIC_CSV="${MATRIX_REPORT_DIR}/mode-static-calibration.csv"
|
|
MATRIX_STATIC_TXT="${MATRIX_REPORT_DIR}/mode-static-calibration.txt"
|
|
MATRIX_STATIC_ENV="${MATRIX_REPORT_DIR}/mode-static-calibration.env"
|
|
MATRIX_RUN_LOG="${MATRIX_REPORT_DIR}/mode-matrix-run.log"
|
|
mkdir -p "${MATRIX_REPORT_DIR}"
|
|
exec > >(tee -a "${MATRIX_RUN_LOG}") 2>&1
|
|
|
|
mode_id() {
|
|
local mode=$1
|
|
printf '%s\n' "${mode//@/_}" | tr -c '[:alnum:]_.-' '_'
|
|
}
|
|
|
|
parse_mode() {
|
|
local mode=$1
|
|
if [[ ! "${mode}" =~ ^([0-9]+)x([0-9]+)@([0-9]+)$ ]]; then
|
|
printf 'invalid mode %s; expected WIDTHxHEIGHT@FPS\n' "${mode}" >&2
|
|
exit 64
|
|
fi
|
|
printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
|
|
}
|
|
|
|
lookup_mode_delay_us() {
|
|
local mode=$1
|
|
local delay_map=$2
|
|
local default_value=$3
|
|
local entry key value
|
|
IFS=',' read -r -a delay_entries <<<"${delay_map}"
|
|
for entry in "${delay_entries[@]}"; do
|
|
key=${entry%%=*}
|
|
value=${entry#*=}
|
|
if [[ "${key}" == "${mode}" && "${value}" =~ ^-?[0-9]+$ ]]; then
|
|
printf '%s\n' "${value}"
|
|
return 0
|
|
fi
|
|
done
|
|
printf '%s\n' "${default_value}"
|
|
}
|
|
|
|
lookup_audio_delay_us() {
|
|
lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}"
|
|
}
|
|
|
|
lookup_video_delay_us() {
|
|
lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US}"
|
|
}
|
|
|
|
artifact_dir_from_log() {
|
|
local log_path=$1
|
|
local search_dir=$2
|
|
local artifact_dir
|
|
artifact_dir="$(awk -F': ' '/^artifact_dir: / {print $2}' "${log_path}" 2>/dev/null | tail -n1 || true)"
|
|
if [[ -z "${artifact_dir}" ]]; then
|
|
artifact_dir="$(find "${search_dir}" -mindepth 1 -maxdepth 1 -type d -name 'lesavka-output-delay-probe-*' | sort | tail -n1 || true)"
|
|
fi
|
|
printf '%s\n' "${artifact_dir}"
|
|
}
|
|
|
|
RUN_MODE_PROBE_STATUS=0
|
|
run_mode_probe() {
|
|
local width=$1
|
|
local height=$2
|
|
local fps=$3
|
|
local audio_delay_us=$4
|
|
local video_delay_us=$5
|
|
local output_dir=$6
|
|
local log_path=$7
|
|
local probe_duration_seconds=${8:-${PROBE_DURATION_SECONDS}}
|
|
local probe_warmup_seconds=${9:-${PROBE_WARMUP_SECONDS}}
|
|
local min_pairs=${10:-${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}}
|
|
local conditioning_seconds=${11:-${LESAVKA_SERVER_RC_SIGNAL_CONDITION_SECONDS}}
|
|
local analysis_timeline_window=${LESAVKA_SERVER_RC_ANALYSIS_TIMELINE_WINDOW}
|
|
if [[ "${LESAVKA_SERVER_RC_SIGNAL_READY}" == "0" ]]; then
|
|
conditioning_seconds=0
|
|
fi
|
|
if [[ "${analysis_timeline_window}" == "auto" ]]; then
|
|
if [[ "${conditioning_seconds}" =~ ^[0-9]+$ && "${conditioning_seconds}" -gt 0 ]]; then
|
|
analysis_timeline_window=1
|
|
else
|
|
analysis_timeline_window=0
|
|
fi
|
|
fi
|
|
|
|
local -a probe_env=(
|
|
"TETHYS_HOST=${TETHYS_HOST}"
|
|
"LESAVKA_SERVER_HOST=${LESAVKA_SERVER_HOST}"
|
|
"LESAVKA_SERVER_CONNECT_HOST=${LESAVKA_SERVER_CONNECT_HOST}"
|
|
"LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR}"
|
|
"LESAVKA_SERVER_SCHEME=${LESAVKA_SERVER_SCHEME}"
|
|
"LESAVKA_TLS_DOMAIN=${LESAVKA_TLS_DOMAIN}"
|
|
"SSH_OPTS=${SSH_OPTS}"
|
|
"REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL}"
|
|
"REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE}"
|
|
"REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK}"
|
|
"REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE}"
|
|
"REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK}"
|
|
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}"
|
|
"REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS}"
|
|
"PROBE_PREBUILD=0"
|
|
"VIDEO_SIZE=${width}x${height}"
|
|
"VIDEO_FPS=${fps}"
|
|
"VIDEO_FORMAT=mjpeg"
|
|
"REMOTE_EXPECT_UVC_WIDTH=${width}"
|
|
"REMOTE_EXPECT_UVC_HEIGHT=${height}"
|
|
"REMOTE_EXPECT_UVC_FPS=${fps}"
|
|
"LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US=${audio_delay_us}"
|
|
"LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=${video_delay_us}"
|
|
"LESAVKA_OUTPUT_DELAY_APPLY=0"
|
|
"LESAVKA_OUTPUT_DELAY_SAVE=0"
|
|
"LESAVKA_OUTPUT_REQUIRE_SYNC_PASS=0"
|
|
"LESAVKA_OUTPUT_DELAY_MIN_PAIRS=${min_pairs}"
|
|
"LESAVKA_OUTPUT_DELAY_MAX_ABS_SKEW_MS=${LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS}"
|
|
"LESAVKA_OUTPUT_DELAY_MAX_DRIFT_MS=${LESAVKA_SERVER_RC_TUNE_MAX_DRIFT_MS}"
|
|
"LESAVKA_OUTPUT_DELAY_MAX_STEP_US=${LESAVKA_SERVER_RC_TUNE_MAX_STEP_US}"
|
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}"
|
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_DRIFT_MS}"
|
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS}"
|
|
"LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS=${min_pairs}"
|
|
"PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES}"
|
|
"PROBE_CONDITIONING_SECONDS=${conditioning_seconds}"
|
|
"PROBE_CONDITIONING_WARMUP_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_WARMUP_SECONDS}"
|
|
"PROBE_CONDITIONING_GAP_SECONDS=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_GAP_SECONDS}"
|
|
"PROBE_CONDITIONING_EVENT_WIDTH_CODES=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_EVENT_WIDTH_CODES}"
|
|
"PROBE_DURATION_SECONDS=${probe_duration_seconds}"
|
|
"PROBE_WARMUP_SECONDS=${probe_warmup_seconds}"
|
|
"ANALYSIS_TIMELINE_WINDOW=${analysis_timeline_window}"
|
|
"LOCAL_OUTPUT_DIR=${output_dir}"
|
|
)
|
|
|
|
set +e
|
|
if [[ "${LESAVKA_SERVER_RC_VERBOSE_PROBES}" == "0" ]]; then
|
|
env "${probe_env[@]}" "${SCRIPT_DIR}/run_upstream_av_sync.sh" >"${log_path}" 2>&1
|
|
RUN_MODE_PROBE_STATUS=$?
|
|
else
|
|
env "${probe_env[@]}" "${SCRIPT_DIR}/run_upstream_av_sync.sh" 2>&1 | tee "${log_path}"
|
|
RUN_MODE_PROBE_STATUS=${PIPESTATUS[0]}
|
|
fi
|
|
set -e
|
|
}
|
|
|
|
write_tune_candidate_env() {
|
|
local mode_result=$1
|
|
local output_env=$2
|
|
python3 - <<'PY' \
|
|
"${mode_result}" \
|
|
"${output_env}" \
|
|
"${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS}" \
|
|
"${LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS}" \
|
|
"${LESAVKA_SERVER_RC_TUNE_MAX_DRIFT_MS}" \
|
|
"${LESAVKA_SERVER_RC_TUNE_MAX_STEP_US}" \
|
|
"${LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US}"
|
|
import json
|
|
import math
|
|
import pathlib
|
|
import shlex
|
|
import sys
|
|
|
|
(
|
|
result_path,
|
|
output_env_path,
|
|
min_pairs_raw,
|
|
max_abs_skew_raw,
|
|
max_drift_raw,
|
|
max_step_raw,
|
|
min_change_raw,
|
|
) = sys.argv[1:8]
|
|
|
|
|
|
def env_line(key, value):
|
|
return f"{key}={shlex.quote(str(value))}\n"
|
|
|
|
|
|
def as_int(value, default=0):
|
|
try:
|
|
return int(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def as_float(value, default=0.0):
|
|
try:
|
|
result = float(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
return result if math.isfinite(result) else default
|
|
|
|
|
|
result = json.loads(pathlib.Path(result_path).read_text())
|
|
calibration = result.get("output_delay_calibration") or {}
|
|
sync = result.get("sync") or {}
|
|
min_pairs = max(1, as_int(min_pairs_raw, 13))
|
|
max_abs_skew_ms = max(1.0, as_float(max_abs_skew_raw, 1000.0))
|
|
max_drift_ms = max(0.0, as_float(max_drift_raw, 80.0))
|
|
max_step_us = max(1, as_int(max_step_raw, 500_000))
|
|
min_change_us = max(0, as_int(min_change_raw, 5_000))
|
|
current_audio = as_int(result.get("audio_delay_us"), 0)
|
|
current_video = as_int(result.get("video_delay_us"), 0)
|
|
target_audio = as_int(calibration.get("audio_target_offset_us"), current_audio)
|
|
target_video = as_int(calibration.get("video_target_offset_us"), current_video)
|
|
paired = as_int(calibration.get("paired_event_count"), as_int(sync.get("paired_event_count"), 0))
|
|
drift_ms = as_float(calibration.get("drift_ms"), as_float(sync.get("drift_ms"), 0.0))
|
|
max_abs_skew_ms_observed = as_float(
|
|
calibration.get("max_abs_skew_ms"),
|
|
as_float(sync.get("p95_abs_skew_ms"), 0.0),
|
|
)
|
|
delta_audio = target_audio - current_audio
|
|
delta_video = target_video - current_video
|
|
raw_delta_us = as_int(
|
|
calibration.get("raw_device_delta_us"),
|
|
delta_video if abs(delta_video) >= abs(delta_audio) else -delta_audio,
|
|
)
|
|
reasons = []
|
|
|
|
if result.get("run_status") != 0:
|
|
reasons.append(f"seed probe exited {result.get('run_status')}")
|
|
if paired < min_pairs:
|
|
reasons.append(f"paired_event_count {paired} < {min_pairs}")
|
|
if max_abs_skew_ms_observed > max_abs_skew_ms:
|
|
reasons.append(
|
|
f"max_abs_skew_ms {max_abs_skew_ms_observed:.1f} > {max_abs_skew_ms:.1f}"
|
|
)
|
|
if abs(drift_ms) > max_drift_ms:
|
|
reasons.append(f"abs(drift_ms) {abs(drift_ms):.1f} > {max_drift_ms:.1f}")
|
|
if abs(raw_delta_us) > max_step_us:
|
|
reasons.append(f"raw delay correction {raw_delta_us:+d}us exceeds {max_step_us}us")
|
|
if target_audio < 0 or target_video < 0:
|
|
reasons.append(
|
|
f"direct confirmation delays must be non-negative, got audio={target_audio} video={target_video}"
|
|
)
|
|
if abs(delta_audio) < min_change_us and abs(delta_video) < min_change_us:
|
|
reasons.append(
|
|
f"target change audio={delta_audio:+d}us video={delta_video:+d}us is below {min_change_us}us"
|
|
)
|
|
|
|
ready = not reasons
|
|
values = {
|
|
"tune_ready": str(ready).lower(),
|
|
"tune_reason": "ready" if ready else "; ".join(reasons),
|
|
"tune_audio_delay_us": target_audio,
|
|
"tune_video_delay_us": target_video,
|
|
"tune_audio_delta_us": delta_audio,
|
|
"tune_video_delta_us": delta_video,
|
|
"tune_paired_event_count": paired,
|
|
"tune_drift_ms": f"{drift_ms:.3f}",
|
|
"tune_max_abs_skew_ms": f"{max_abs_skew_ms_observed:.3f}",
|
|
"tune_raw_delta_us": raw_delta_us,
|
|
}
|
|
with pathlib.Path(output_env_path).open("w") as handle:
|
|
for key, value in values.items():
|
|
handle.write(env_line(key, value))
|
|
PY
|
|
}
|
|
|
|
signal_readiness_passed() {
|
|
local artifact_dir=$1
|
|
local min_pairs=$2
|
|
python3 - <<'PY' "${artifact_dir}" "${min_pairs}"
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
artifact_dir = pathlib.Path(sys.argv[1])
|
|
min_pairs = int(sys.argv[2])
|
|
|
|
def load_json(path):
|
|
try:
|
|
return json.loads(path.read_text())
|
|
except Exception:
|
|
return {}
|
|
|
|
report = load_json(artifact_dir / "report.json")
|
|
paired = int(report.get("paired_event_count") or 0)
|
|
video = int(report.get("video_event_count") or 0)
|
|
audio = int(report.get("audio_event_count") or 0)
|
|
verdict = report.get("verdict") or {}
|
|
passed = paired >= min_pairs and video >= min_pairs and audio >= min_pairs and verdict.get("passed") is True
|
|
if not passed:
|
|
reason = (
|
|
f"signal readiness needs at least {min_pairs} paired coded events with sync pass; "
|
|
f"saw paired={paired}, video={video}, audio={audio}, sync={verdict.get('status', 'unknown')}"
|
|
)
|
|
print(reason)
|
|
raise SystemExit(0 if passed else 1)
|
|
PY
|
|
}
|
|
|
|
write_signal_readiness_attempt_result() {
|
|
local attempt=$1
|
|
local artifact_dir=$2
|
|
local run_status=$3
|
|
local run_log=$4
|
|
local min_pairs=$5
|
|
local output_json=$6
|
|
python3 - <<'PY' \
|
|
"${attempt}" \
|
|
"${artifact_dir}" \
|
|
"${run_status}" \
|
|
"${run_log}" \
|
|
"${min_pairs}" \
|
|
"${output_json}"
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
(
|
|
attempt_raw,
|
|
artifact_dir_raw,
|
|
run_status_raw,
|
|
run_log_raw,
|
|
min_pairs_raw,
|
|
output_json_raw,
|
|
) = sys.argv[1:]
|
|
|
|
def as_int(value, default=0):
|
|
try:
|
|
return int(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
def as_float(value, default=0.0):
|
|
try:
|
|
return float(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
def load_json(path):
|
|
try:
|
|
return json.loads(path.read_text())
|
|
except Exception:
|
|
return {}
|
|
|
|
def read_text(path):
|
|
try:
|
|
return path.read_text(errors="replace")
|
|
except Exception:
|
|
return ""
|
|
|
|
attempt = as_int(attempt_raw)
|
|
run_status = as_int(run_status_raw)
|
|
min_pairs = as_int(min_pairs_raw, 1)
|
|
run_log = pathlib.Path(run_log_raw)
|
|
artifact_dir = pathlib.Path(artifact_dir_raw) if artifact_dir_raw else None
|
|
if artifact_dir is None or not artifact_dir.exists():
|
|
match = re.findall(r"^artifact_dir:\s*(.+)$", read_text(run_log), flags=re.MULTILINE)
|
|
if match:
|
|
artifact_dir = pathlib.Path(match[-1].strip())
|
|
if artifact_dir is None:
|
|
artifact_dir = pathlib.Path("__missing_signal_readiness_artifact__")
|
|
|
|
report = load_json(artifact_dir / "report.json")
|
|
timeline = load_json(artifact_dir / "server-output-timeline.json")
|
|
error_log = artifact_dir / "analysis-error.log"
|
|
error_text = read_text(error_log)
|
|
if not error_text:
|
|
error_log = artifact_dir / "analysis-error.log.tmp"
|
|
error_text = read_text(error_log)
|
|
if not error_text:
|
|
error_text = read_text(run_log)
|
|
|
|
events = timeline.get("events") or []
|
|
server_event_count = len(events)
|
|
server_video_handoff_count = sum(
|
|
1
|
|
for event in events
|
|
if event.get("video_feed_unix_ns") is not None
|
|
or event.get("video_feed_monotonic_us") is not None
|
|
)
|
|
server_audio_handoff_count = sum(
|
|
1
|
|
for event in events
|
|
if event.get("audio_push_unix_ns") is not None
|
|
or event.get("audio_push_monotonic_us") is not None
|
|
)
|
|
|
|
verdict = report.get("verdict") or {}
|
|
signature_coverage = report.get("signature_coverage") or {}
|
|
video_events = as_int(report.get("video_event_count"))
|
|
audio_events = as_int(report.get("audio_event_count"))
|
|
paired_events = as_int(report.get("paired_event_count"))
|
|
coded_pairs = as_int(signature_coverage.get("paired_event_count"), paired_events)
|
|
expected_pairs = as_int(signature_coverage.get("expected_event_count"))
|
|
raw_activity_delta_ms = as_float(report.get("activity_start_delta_ms"))
|
|
analyzer_failure = ""
|
|
|
|
failure_match = re.search(
|
|
r"coded pulse common window removed one stream entirely;[^\n]*"
|
|
r"\(video=(?P<video>\d+)\s+audio=(?P<audio>\d+)\s+raw activity delta\s+"
|
|
r"(?P<delta>[+-]?\d+(?:\.\d+)?)\s+ms\)",
|
|
error_text,
|
|
)
|
|
if failure_match:
|
|
video_events = max(video_events, as_int(failure_match.group("video")))
|
|
audio_events = max(audio_events, as_int(failure_match.group("audio")))
|
|
raw_activity_delta_ms = as_float(failure_match.group("delta"))
|
|
analyzer_failure = failure_match.group(0)
|
|
|
|
pairs_match = re.search(
|
|
r"need at least\s+(?P<needed>\d+)\s+matching coded pulse pairs;\s+"
|
|
r"saw\s+(?P<paired>\d+);\s+raw activity delta was\s+"
|
|
r"(?P<delta>[+-]?\d+(?:\.\d+)?)\s+ms\s+"
|
|
r"\(video=(?P<video_time>\d+(?:\.\d+)?)s\s+audio=(?P<audio_time>\d+(?:\.\d+)?)s\)",
|
|
error_text,
|
|
)
|
|
if pairs_match:
|
|
paired_from_error = as_int(pairs_match.group("paired"))
|
|
paired_events = max(paired_events, paired_from_error)
|
|
coded_pairs = max(coded_pairs, paired_from_error)
|
|
video_events = max(video_events, paired_from_error)
|
|
audio_events = max(audio_events, paired_from_error)
|
|
raw_activity_delta_ms = as_float(pairs_match.group("delta"))
|
|
analyzer_failure = pairs_match.group(0)
|
|
|
|
sync_passed = verdict.get("passed") is True
|
|
paired_ready = paired_events >= min_pairs or coded_pairs >= min_pairs
|
|
video_ready = video_events >= min_pairs
|
|
audio_ready = audio_events >= min_pairs
|
|
passed = run_status == 0 and paired_ready and video_ready and audio_ready and sync_passed
|
|
|
|
reasons = []
|
|
if run_status != 0:
|
|
reasons.append(f"probe command exited {run_status}")
|
|
if server_event_count == 0:
|
|
reasons.append("server did not report generated coded events")
|
|
if server_video_handoff_count == 0:
|
|
reasons.append("server did not report video sink handoff events")
|
|
if server_audio_handoff_count == 0:
|
|
reasons.append("server did not report audio sink handoff events")
|
|
if video_events < min_pairs:
|
|
reasons.append(f"Tethys video coded events {video_events} < {min_pairs}")
|
|
if audio_events < min_pairs:
|
|
reasons.append(f"Tethys audio coded events {audio_events} < {min_pairs}")
|
|
if max(paired_events, coded_pairs) < min_pairs:
|
|
reasons.append(f"paired coded events {max(paired_events, coded_pairs)} < {min_pairs}")
|
|
if report and not sync_passed:
|
|
reasons.append(f"sync verdict did not pass: {verdict.get('status', 'unknown')}")
|
|
if analyzer_failure:
|
|
reasons.append(analyzer_failure)
|
|
if not reasons and passed:
|
|
reasons.append("ready")
|
|
elif not reasons:
|
|
reasons.append("signal readiness failed")
|
|
|
|
result = {
|
|
"schema": "lesavka.server-rc-signal-readiness-attempt.v1",
|
|
"attempt": attempt,
|
|
"passed": passed,
|
|
"reason": "; ".join(reasons),
|
|
"run_status": run_status,
|
|
"run_log": str(run_log),
|
|
"artifact_dir": str(artifact_dir) if artifact_dir.exists() else "",
|
|
"analysis_error_log": str(error_log) if error_log.exists() else "",
|
|
"counts": {
|
|
"server_events": server_event_count,
|
|
"server_video_handoffs": server_video_handoff_count,
|
|
"server_audio_handoffs": server_audio_handoff_count,
|
|
"video_events": video_events,
|
|
"audio_events": audio_events,
|
|
"paired_events": paired_events,
|
|
"coded_pairs": coded_pairs,
|
|
"expected_pairs": expected_pairs,
|
|
},
|
|
"layers": {
|
|
"server_generated_video": server_event_count > 0,
|
|
"server_generated_audio": server_event_count > 0,
|
|
"server_video_handoff": server_video_handoff_count > 0,
|
|
"server_audio_handoff": server_audio_handoff_count > 0,
|
|
"tethys_video_detected": video_events > 0,
|
|
"tethys_audio_detected": audio_events > 0,
|
|
"tethys_video_ready": video_ready,
|
|
"tethys_audio_ready": audio_ready,
|
|
"paired_coded_ready": paired_ready,
|
|
"analyzer_sync_passed": sync_passed,
|
|
},
|
|
"sync": {
|
|
"status": verdict.get("status", "unknown"),
|
|
"passed": sync_passed,
|
|
"p95_abs_skew_ms": as_float(verdict.get("p95_abs_skew_ms")),
|
|
"median_skew_ms": as_float(report.get("median_skew_ms")),
|
|
"drift_ms": as_float(report.get("drift_ms")),
|
|
"raw_activity_delta_ms": raw_activity_delta_ms,
|
|
},
|
|
"analyzer_failure": analyzer_failure,
|
|
}
|
|
pathlib.Path(output_json_raw).write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
|
|
print(result["reason"])
|
|
raise SystemExit(0 if passed else 1)
|
|
PY
|
|
}
|
|
|
|
write_signal_readiness_attempts_summary() {
|
|
local output_json=$1
|
|
shift || true
|
|
python3 - <<'PY' "${output_json}" "$@"
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
output = pathlib.Path(sys.argv[1])
|
|
attempts = []
|
|
for raw in sys.argv[2:]:
|
|
path = pathlib.Path(raw)
|
|
try:
|
|
attempts.append(json.loads(path.read_text()))
|
|
except Exception:
|
|
attempts.append({
|
|
"schema": "lesavka.server-rc-signal-readiness-attempt.v1",
|
|
"attempt_json": str(path),
|
|
"passed": False,
|
|
"reason": "attempt result could not be read",
|
|
})
|
|
passed_attempt = next((attempt for attempt in attempts if attempt.get("passed")), None)
|
|
summary = {
|
|
"schema": "lesavka.server-rc-signal-readiness-summary.v1",
|
|
"passed": passed_attempt is not None,
|
|
"passed_attempt": passed_attempt.get("attempt") if passed_attempt else None,
|
|
"attempt_count": len(attempts),
|
|
"final_reason": (
|
|
"ready"
|
|
if passed_attempt
|
|
else (attempts[-1].get("reason") if attempts else "no readiness attempts ran")
|
|
),
|
|
"attempts": attempts,
|
|
}
|
|
output.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
|
PY
|
|
}
|
|
|
|
write_signal_readiness_failure() {
|
|
local mode=$1
|
|
local width=$2
|
|
local height=$3
|
|
local fps=$4
|
|
local video_delay_us=$5
|
|
local audio_delay_us=$6
|
|
local readiness_status=$7
|
|
local readiness_log=$8
|
|
local readiness_artifact_dir=$9
|
|
local readiness_attempts_json=${10}
|
|
local readiness_reason=${11}
|
|
local output_json=${12}
|
|
python3 - <<'PY' \
|
|
"${mode}" \
|
|
"${width}" \
|
|
"${height}" \
|
|
"${fps}" \
|
|
"${video_delay_us}" \
|
|
"${audio_delay_us}" \
|
|
"${readiness_status}" \
|
|
"${readiness_log}" \
|
|
"${readiness_artifact_dir}" \
|
|
"${readiness_attempts_json}" \
|
|
"${readiness_reason}" \
|
|
"${output_json}"
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
(
|
|
mode,
|
|
width_raw,
|
|
height_raw,
|
|
fps_raw,
|
|
video_delay_raw,
|
|
audio_delay_raw,
|
|
readiness_status_raw,
|
|
readiness_log,
|
|
readiness_artifact_dir,
|
|
readiness_attempts_json,
|
|
readiness_reason,
|
|
output_json,
|
|
) = sys.argv[1:]
|
|
|
|
def as_int(value, default=0):
|
|
try:
|
|
return int(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
def load_json(path):
|
|
try:
|
|
return json.loads(pathlib.Path(path).read_text())
|
|
except Exception:
|
|
return {}
|
|
|
|
report = load_json(pathlib.Path(readiness_artifact_dir) / "report.json")
|
|
verdict = report.get("verdict") or {}
|
|
signature_coverage = report.get("signature_coverage") or {}
|
|
readiness_attempts = load_json(readiness_attempts_json).get("attempts") if readiness_attempts_json else None
|
|
result = {
|
|
"schema": "lesavka.server-rc-mode-result.v1",
|
|
"mode": mode,
|
|
"width": as_int(width_raw),
|
|
"height": as_int(height_raw),
|
|
"fps": as_int(fps_raw),
|
|
"audio_delay_us": as_int(audio_delay_raw),
|
|
"video_delay_us": as_int(video_delay_raw),
|
|
"run_status": as_int(readiness_status_raw),
|
|
"run_log": readiness_log,
|
|
"artifact_dir": readiness_artifact_dir,
|
|
"report_json": str(pathlib.Path(readiness_artifact_dir) / "report.json"),
|
|
"passed": False,
|
|
"failure_reasons": [f"signal readiness did not pass: {readiness_reason}"],
|
|
"signal_readiness": {
|
|
"passed": False,
|
|
"reason": readiness_reason,
|
|
"artifact_dir": readiness_artifact_dir,
|
|
"run_log": readiness_log,
|
|
"attempts_json": readiness_attempts_json,
|
|
"attempts": readiness_attempts or [],
|
|
},
|
|
"sync": {
|
|
"passed": verdict.get("passed") is True,
|
|
"status": verdict.get("status", "unknown"),
|
|
"reason": verdict.get("reason", ""),
|
|
"p95_abs_skew_ms": float(verdict.get("p95_abs_skew_ms") or 0.0),
|
|
"median_skew_ms": float(report.get("median_skew_ms") or 0.0),
|
|
"drift_ms": float(report.get("drift_ms") or 0.0),
|
|
"activity_start_delta_ms": float(report.get("activity_start_delta_ms") or 0.0),
|
|
"first_skew_ms": float(report.get("first_skew_ms") or 0.0),
|
|
"video_event_count": as_int(report.get("video_event_count")),
|
|
"audio_event_count": as_int(report.get("audio_event_count")),
|
|
"paired_event_count": as_int(report.get("paired_event_count")),
|
|
"signature_expected_event_count": as_int(signature_coverage.get("expected_event_count")),
|
|
"signature_paired_event_count": as_int(signature_coverage.get("paired_event_count")),
|
|
"signature_missing_event_ids": signature_coverage.get("missing_event_ids") or [],
|
|
"signature_missing_codes": signature_coverage.get("missing_codes") or [],
|
|
"signature_unknown_pair_identity_count": as_int(signature_coverage.get("unknown_pair_identity_count")),
|
|
},
|
|
"freshness": {
|
|
"status": "unknown",
|
|
"reason": "measured probe skipped because signal readiness failed",
|
|
"worst_event_age_with_uncertainty_ms": 0.0,
|
|
},
|
|
"smoothness": {},
|
|
"output_delay_calibration": {},
|
|
}
|
|
pathlib.Path(output_json).write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
|
|
PY
|
|
}
|
|
|
|
discover_local_webcam_modes() {
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
printf 'LESAVKA_SERVER_RC_MODES=auto requires python3 for local webcam mode discovery.\n' >&2
|
|
exit 64
|
|
fi
|
|
if ! command -v v4l2-ctl >/dev/null 2>&1; then
|
|
printf 'LESAVKA_SERVER_RC_MODES=auto requires v4l2-ctl for local webcam mode discovery.\n' >&2
|
|
exit 64
|
|
fi
|
|
python3 - <<'PY' \
|
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES}" \
|
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS}" \
|
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX}" \
|
|
"${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX}"
|
|
import glob
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
sizes_raw, fps_raw, include_raw, exclude_raw = sys.argv[1:5]
|
|
|
|
def parse_sizes(raw):
|
|
sizes = set()
|
|
for entry in raw.split(","):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
match = re.fullmatch(r"(\d+)x(\d+)", entry)
|
|
if not match:
|
|
raise SystemExit(f"invalid discovery size {entry!r}; expected WIDTHxHEIGHT")
|
|
sizes.add((int(match.group(1)), int(match.group(2))))
|
|
return sizes
|
|
|
|
def parse_fps(raw):
|
|
values = set()
|
|
for entry in raw.split(","):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
if not entry.isdigit():
|
|
raise SystemExit(f"invalid discovery fps {entry!r}; expected integer FPS")
|
|
values.add(int(entry))
|
|
return values
|
|
|
|
allowed_sizes = parse_sizes(sizes_raw)
|
|
allowed_fps = parse_fps(fps_raw)
|
|
include = re.compile(include_raw, re.IGNORECASE) if include_raw else None
|
|
exclude = re.compile(exclude_raw, re.IGNORECASE) if exclude_raw else None
|
|
all_modes = set()
|
|
camera_modes = []
|
|
|
|
for dev in sorted(glob.glob("/dev/video*")):
|
|
try:
|
|
info = subprocess.run(
|
|
["v4l2-ctl", "-d", dev, "--info"],
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
).stdout
|
|
formats = subprocess.run(
|
|
["v4l2-ctl", "-d", dev, "--list-formats-ext"],
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
).stdout
|
|
except Exception:
|
|
continue
|
|
|
|
card = dev
|
|
for line in info.splitlines():
|
|
if "Card type" in line:
|
|
card = line.split(":", 1)[1].strip()
|
|
break
|
|
label = f"{card} {dev}"
|
|
if exclude and exclude.search(label):
|
|
continue
|
|
if include and not include.search(label):
|
|
continue
|
|
|
|
current_size = None
|
|
modes = set()
|
|
for line in formats.splitlines():
|
|
size_match = re.search(r"Size:\s+Discrete\s+(\d+)x(\d+)", line)
|
|
if size_match:
|
|
current_size = (int(size_match.group(1)), int(size_match.group(2)))
|
|
continue
|
|
fps_match = re.search(r"Interval:\s+Discrete\s+[^()]+\((\d+(?:\.\d+)?) fps\)", line)
|
|
if not fps_match or current_size not in allowed_sizes:
|
|
continue
|
|
fps_float = float(fps_match.group(1))
|
|
fps = int(round(fps_float))
|
|
if abs(fps_float - fps) > 0.05 or fps not in allowed_fps:
|
|
continue
|
|
modes.add((current_size[0], current_size[1], fps))
|
|
|
|
if modes:
|
|
all_modes.update(modes)
|
|
camera_modes.append((label, modes))
|
|
|
|
if not all_modes:
|
|
print(
|
|
"no matching local webcam modes found; "
|
|
f"sizes={sizes_raw} fps={fps_raw} include={include_raw!r} exclude={exclude_raw!r}",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(64)
|
|
|
|
for label, modes in camera_modes:
|
|
rendered = ",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(modes))
|
|
print(f" -> local webcam {label}: {rendered}", file=sys.stderr)
|
|
|
|
print(",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(all_modes)), end="")
|
|
PY
|
|
}
|
|
|
|
clear_remote_sudo_password() {
|
|
LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD=""
|
|
}
|
|
trap clear_remote_sudo_password EXIT
|
|
|
|
ensure_remote_sudo_password() {
|
|
[[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0
|
|
[[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0
|
|
[[ -n "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}" ]] && return 0
|
|
|
|
if [[ "${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY}" == "0" ]]; then
|
|
printf 'remote sudo password is required for automatic reconfigure; set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD or leave LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=1.\n' >&2
|
|
exit 64
|
|
fi
|
|
if [[ ! -t 0 ]]; then
|
|
printf 'remote sudo password is required, but stdin is not a terminal.\n' >&2
|
|
printf 'Re-run from a terminal or set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD in the environment.\n' >&2
|
|
exit 64
|
|
fi
|
|
|
|
printf 'Theia sudo password for %s: ' "${LESAVKA_SERVER_HOST}" >&2
|
|
IFS= read -r -s LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD
|
|
printf '\n' >&2
|
|
}
|
|
|
|
run_remote_root_script() {
|
|
local description=$1
|
|
shift
|
|
local script_file remote_wrapper ssh_cmd status
|
|
script_file="$(mktemp)"
|
|
cat >"${script_file}"
|
|
|
|
remote_wrapper='set -euo pipefail
|
|
read -r __lesavka_sudo_password
|
|
__lesavka_script=$(mktemp)
|
|
cleanup() { rm -f "$__lesavka_script"; }
|
|
trap cleanup EXIT
|
|
cat >"$__lesavka_script"
|
|
printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" -v
|
|
printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" bash "$__lesavka_script" "$@"
|
|
'
|
|
printf -v ssh_cmd 'bash -c %q _' "${remote_wrapper}"
|
|
for arg in "$@"; do
|
|
printf -v ssh_cmd '%s %q' "${ssh_cmd}" "${arg}"
|
|
done
|
|
|
|
set +e
|
|
{
|
|
printf '%s\n' "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}"
|
|
cat "${script_file}"
|
|
} | ssh ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" "${ssh_cmd}"
|
|
status=$?
|
|
set -e
|
|
rm -f "${script_file}"
|
|
if [[ "${status}" -ne 0 ]]; then
|
|
printf '%s failed on %s with exit %s\n' "${description}" "${LESAVKA_SERVER_HOST}" "${status}" >&2
|
|
fi
|
|
return "${status}"
|
|
}
|
|
|
|
prime_remote_sudo() {
|
|
[[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0
|
|
[[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0
|
|
ensure_remote_sudo_password
|
|
echo "==> priming remote sudo on ${LESAVKA_SERVER_HOST}"
|
|
run_remote_root_script "remote sudo prime" <<'REMOTE_SUDO_PRIME'
|
|
set -euo pipefail
|
|
true
|
|
REMOTE_SUDO_PRIME
|
|
}
|
|
|
|
sleep_start_delay() {
|
|
[[ "${LESAVKA_SERVER_RC_START_DELAY_SECONDS}" != "0" ]] || return 0
|
|
if [[ ! "${LESAVKA_SERVER_RC_START_DELAY_SECONDS}" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
|
|
printf 'LESAVKA_SERVER_RC_START_DELAY_SECONDS must be a non-negative number, got %s\n' "${LESAVKA_SERVER_RC_START_DELAY_SECONDS}" >&2
|
|
exit 64
|
|
fi
|
|
echo "==> delaying server-to-RC matrix start for ${LESAVKA_SERVER_RC_START_DELAY_SECONDS}s"
|
|
echo " ↪ remote sudo has already been primed; sleeping before prebuild/reconfigure/capture"
|
|
sleep "${LESAVKA_SERVER_RC_START_DELAY_SECONDS}"
|
|
}
|
|
|
|
prebuild_probe_tools() {
|
|
[[ "${LESAVKA_SERVER_RC_PROBE_PREBUILD}" != "0" ]] || return 0
|
|
echo "==> prebuilding relay control/analyzer once for the mode matrix"
|
|
(
|
|
cd "${REPO_ROOT}"
|
|
cargo build -p lesavka_client --bin lesavka-sync-analyze --bin lesavka-relayctl
|
|
)
|
|
}
|
|
|
|
reconfigure_server_mode() {
|
|
local mode=$1
|
|
local width=$2
|
|
local height=$3
|
|
local fps=$4
|
|
local audio_delay_us=${5:-$(lookup_audio_delay_us "${mode}")}
|
|
local video_delay_us=${6:-$(lookup_video_delay_us "${mode}")}
|
|
[[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0
|
|
|
|
echo "==> reconfiguring ${LESAVKA_SERVER_HOST} UVC gadget for ${mode}"
|
|
if [[ -n "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]]; then
|
|
LESAVKA_MODE="${mode}" \
|
|
LESAVKA_UVC_WIDTH="${width}" \
|
|
LESAVKA_UVC_HEIGHT="${height}" \
|
|
LESAVKA_UVC_FPS="${fps}" \
|
|
LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US="${audio_delay_us}" \
|
|
LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US="${video_delay_us}" \
|
|
bash -c "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" != "runtime" ]]; then
|
|
printf 'unsupported LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=%s\n' "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" >&2
|
|
printf 'Use runtime for fast mode changes, or LESAVKA_SERVER_RC_RECONFIGURE_COMMAND for a custom install/reconfigure command.\n' >&2
|
|
return 64
|
|
fi
|
|
|
|
local interval
|
|
interval=$((10000000 / fps))
|
|
run_remote_root_script "runtime UVC reconfigure for ${mode}" \
|
|
"${mode}" \
|
|
"${width}" \
|
|
"${height}" \
|
|
"${fps}" \
|
|
"${interval}" \
|
|
"${LESAVKA_SERVER_RC_RECONFIGURE_CODEC}" \
|
|
"${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" \
|
|
"${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD}" \
|
|
"${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS}" \
|
|
"${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE}" \
|
|
"${audio_delay_us}" \
|
|
"${video_delay_us}" \
|
|
"${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" \
|
|
"${LESAVKA_SERVER_RC_MODE_DELAYS_US}" <<'REMOTE_RECONFIGURE'
|
|
set -euo pipefail
|
|
mode=$1
|
|
width=$2
|
|
height=$3
|
|
fps=$4
|
|
interval=$5
|
|
codec=$6
|
|
allow_gadget_reset=$7
|
|
force_gadget_rebuild=$8
|
|
settle_seconds=$9
|
|
verbose=${10}
|
|
audio_delay_us=${11}
|
|
video_delay_us=${12}
|
|
audio_delay_map=${13}
|
|
video_delay_map=${14}
|
|
|
|
set_env_value() {
|
|
local file=$1
|
|
local key=$2
|
|
local value=$3
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
if [[ -f "${file}" ]]; then
|
|
awk -v key="${key}" -v value="${value}" '
|
|
BEGIN { wrote = 0 }
|
|
$0 ~ "^" key "=" {
|
|
print key "=" value
|
|
wrote = 1
|
|
next
|
|
}
|
|
{ print }
|
|
END {
|
|
if (!wrote) {
|
|
print key "=" value
|
|
}
|
|
}
|
|
' "${file}" >"${tmp}"
|
|
else
|
|
printf '%s=%s\n' "${key}" "${value}" >"${tmp}"
|
|
fi
|
|
install -m 0644 "${tmp}" "${file}"
|
|
rm -f "${tmp}"
|
|
}
|
|
|
|
if [[ ! -x /usr/local/bin/lesavka-core.sh ]]; then
|
|
printf 'missing /usr/local/bin/lesavka-core.sh; run the server installer once before using fast runtime reconfigure.\n' >&2
|
|
exit 65
|
|
fi
|
|
if [[ ! -x /usr/local/bin/lesavka-server || ! -x /usr/local/bin/lesavka-uvc ]]; then
|
|
printf 'missing installed Lesavka binaries; run the server installer once before using fast runtime reconfigure.\n' >&2
|
|
exit 65
|
|
fi
|
|
|
|
install -d -m 0755 /etc/lesavka
|
|
touch /etc/lesavka/server.env /etc/lesavka/uvc.env
|
|
|
|
set_env_value /etc/lesavka/server.env LESAVKA_CAM_OUTPUT uvc
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_CODEC "${codec}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_WIDTH "${width}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_HEIGHT "${height}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_FPS "${fps}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UVC_INTERVAL "${interval}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US "${audio_delay_map}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_VIDEO_PLAYOUT_MODE_OFFSETS_US "${video_delay_map}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US "${audio_delay_us}"
|
|
set_env_value /etc/lesavka/server.env LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US "${video_delay_us}"
|
|
|
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_CODEC "${codec}"
|
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_WIDTH "${width}"
|
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_HEIGHT "${height}"
|
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_FPS "${fps}"
|
|
set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_INTERVAL "${interval}"
|
|
|
|
printf ' ↪ fast runtime env updated: CAM_OUTPUT=uvc UVC_MODE=%s codec=%s\n' "${mode}" "${codec}"
|
|
systemctl daemon-reload
|
|
systemctl stop lesavka-server >/dev/null 2>&1 || true
|
|
systemctl stop lesavka-uvc >/dev/null 2>&1 || true
|
|
systemctl reset-failed lesavka-core lesavka-uvc lesavka-server >/dev/null 2>&1 || true
|
|
|
|
if [[ "${allow_gadget_reset}" != "0" && "${force_gadget_rebuild}" != "0" ]]; then
|
|
printf ' ↪ cycling UVC gadget descriptors for %s\n' "${mode}"
|
|
mode_log_id=$(printf '%s' "${mode}" | tr -c '[:alnum:]_.-' '_')
|
|
core_log="/tmp/lesavka-core-reconfigure-${mode_log_id}.log"
|
|
core_cmd=(
|
|
env
|
|
LESAVKA_ALLOW_GADGET_RESET=1 \
|
|
LESAVKA_FORCE_GADGET_REBUILD=1 \
|
|
LESAVKA_ATTACH_WRITE_UDC=1 \
|
|
LESAVKA_DETACH_CLEAR_UDC=1 \
|
|
LESAVKA_UVC_FALLBACK=0 \
|
|
LESAVKA_UVC_CODEC="${codec}" \
|
|
LESAVKA_UVC_WIDTH="${width}" \
|
|
LESAVKA_UVC_HEIGHT="${height}" \
|
|
LESAVKA_UVC_FPS="${fps}" \
|
|
LESAVKA_UVC_INTERVAL="${interval}" \
|
|
/usr/local/bin/lesavka-core.sh
|
|
)
|
|
if [[ "${verbose}" != "0" ]]; then
|
|
"${core_cmd[@]}"
|
|
else
|
|
if "${core_cmd[@]}" >"${core_log}" 2>&1; then
|
|
printf ' ↪ lesavka-core reconfigure log: %s\n' "${core_log}"
|
|
else
|
|
rc=$?
|
|
printf 'lesavka-core reconfigure failed; tail of %s follows\n' "${core_log}" >&2
|
|
tail -n 80 "${core_log}" >&2 || true
|
|
exit "${rc}"
|
|
fi
|
|
fi
|
|
elif [[ "${allow_gadget_reset}" != "0" ]]; then
|
|
printf ' ↪ gadget reset allowed, but force rebuild disabled; refreshing services only\n'
|
|
else
|
|
printf ' ↪ preserving attached UVC gadget; descriptors may remain on the previous mode\n'
|
|
fi
|
|
|
|
systemctl start lesavka-uvc
|
|
systemctl restart lesavka-server
|
|
sleep "${settle_seconds}"
|
|
systemctl is-active lesavka-core lesavka-uvc lesavka-server >/dev/null
|
|
printf ' ↪ services active after %s reconfigure\n' "${mode}"
|
|
REMOTE_RECONFIGURE
|
|
}
|
|
|
|
wait_tethys_media_ready() {
|
|
local mode=$1
|
|
local width=$2
|
|
local height=$3
|
|
local fps=$4
|
|
[[ "${LESAVKA_SERVER_RC_WAIT_TETHYS_READY}" != "0" ]] || return 0
|
|
|
|
echo "==> waiting for Tethys media endpoints for ${mode}"
|
|
ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
|
|
"${mode}" \
|
|
"${width}" \
|
|
"${height}" \
|
|
"${fps}" \
|
|
"${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}" \
|
|
"${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}" \
|
|
"${REMOTE_CAPTURE_STACK}" \
|
|
"${REMOTE_AUDIO_SOURCE}" <<'REMOTE_TETHYS_READY'
|
|
set -euo pipefail
|
|
mode=$1
|
|
width=$2
|
|
height=$3
|
|
fps=$4
|
|
timeout_seconds=$5
|
|
settle_seconds=$6
|
|
capture_stack=$7
|
|
audio_source=$8
|
|
|
|
sleep "${settle_seconds}"
|
|
|
|
find_lesavka_video_device() {
|
|
if [[ -d /dev/v4l/by-id ]]; then
|
|
while IFS= read -r path; do
|
|
[[ -e "${path}" ]] || continue
|
|
printf '%s\n' "${path}"
|
|
return 0
|
|
done < <(find /dev/v4l/by-id -maxdepth 1 -type l -name '*Lesavka*video-index0' 2>/dev/null | sort)
|
|
fi
|
|
|
|
if command -v v4l2-ctl >/dev/null 2>&1; then
|
|
v4l2-ctl --list-devices 2>/dev/null \
|
|
| awk '
|
|
BEGIN { want=0 }
|
|
/Lesavka Composite|Lesavka.*UVC/ { want=1; next }
|
|
/^[^ \t]/ { want=0 }
|
|
want && /^[ \t]+\/dev\/video[0-9]+/ {
|
|
gsub(/^[ \t]+/, "", $0)
|
|
print
|
|
exit
|
|
}
|
|
'
|
|
fi
|
|
}
|
|
|
|
video_ready() {
|
|
local dev=$1
|
|
[[ -n "${dev}" ]] || return 1
|
|
[[ -e "${dev}" ]] || return 1
|
|
if ! command -v v4l2-ctl >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
local listing
|
|
listing="$(v4l2-ctl -d "${dev}" --list-formats-ext 2>/dev/null || true)"
|
|
grep -q "Size: Discrete ${width}x${height}" <<<"${listing}" || return 1
|
|
grep -Eq "(^|[^0-9])${fps}(\\.0+)? fps" <<<"${listing}" || return 1
|
|
}
|
|
|
|
pulse_ready() {
|
|
if ! command -v pactl >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
if [[ "${audio_source}" == pulse:* ]]; then
|
|
local requested=${audio_source#pulse:}
|
|
pactl list short sources 2>/dev/null | awk -v requested="${requested}" '$2 == requested { found=1 } END { exit found ? 0 : 1 }'
|
|
return
|
|
fi
|
|
pactl list short sources 2>/dev/null | grep -Eq 'alsa_input\..*Lesavka_Composite|Lesavka_Composite'
|
|
}
|
|
|
|
alsa_ready() {
|
|
if [[ "${audio_source}" == alsa:* ]]; then
|
|
[[ -e "${audio_source#alsa:}" ]] || return 1
|
|
return 0
|
|
fi
|
|
command -v arecord >/dev/null 2>&1 || return 1
|
|
arecord -l 2>/dev/null | grep -Eq 'Lesavka|UAC2_Gadget|UAC2Gadget|Composite'
|
|
}
|
|
|
|
audio_ready() {
|
|
case "${capture_stack}" in
|
|
pulse)
|
|
pulse_ready
|
|
;;
|
|
alsa)
|
|
alsa_ready
|
|
;;
|
|
auto)
|
|
pulse_ready || alsa_ready
|
|
;;
|
|
pwpipe)
|
|
command -v pw-dump >/dev/null 2>&1 && pw-dump 2>/dev/null | grep -Eq 'Lesavka_Composite|Lesavka Composite'
|
|
;;
|
|
*)
|
|
pulse_ready || alsa_ready
|
|
;;
|
|
esac
|
|
}
|
|
|
|
deadline=$((SECONDS + timeout_seconds))
|
|
last_video='none'
|
|
last_audio='none'
|
|
while (( SECONDS <= deadline )); do
|
|
video_dev="$(find_lesavka_video_device || true)"
|
|
last_video="${video_dev:-none}"
|
|
if [[ -n "${video_dev}" ]] && video_ready "${video_dev}"; then
|
|
if audio_ready; then
|
|
printf ' ↪ Tethys media ready: video=%s mode=%s audio_stack=%s\n' "${video_dev}" "${mode}" "${capture_stack}"
|
|
exit 0
|
|
fi
|
|
last_audio='not-ready'
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
|
|
printf 'timed out waiting for Tethys Lesavka media endpoints for %s after %ss\n' "${mode}" "${timeout_seconds}" >&2
|
|
printf 'last video candidate: %s\n' "${last_video}" >&2
|
|
printf 'last audio candidate: %s\n' "${last_audio}" >&2
|
|
if command -v v4l2-ctl >/dev/null 2>&1; then
|
|
v4l2-ctl --list-devices >&2 || true
|
|
if [[ "${last_video}" != "none" ]]; then
|
|
v4l2-ctl -d "${last_video}" --list-formats-ext >&2 || true
|
|
fi
|
|
fi
|
|
if command -v pactl >/dev/null 2>&1; then
|
|
pactl list short sources | grep -iE 'lesavka|uac|composite' >&2 || true
|
|
fi
|
|
if command -v arecord >/dev/null 2>&1; then
|
|
arecord -l >&2 || true
|
|
fi
|
|
exit 70
|
|
REMOTE_TETHYS_READY
|
|
}
|
|
|
|
write_mode_result() {
|
|
local mode=$1
|
|
local width=$2
|
|
local height=$3
|
|
local fps=$4
|
|
local video_delay_us=$5
|
|
local audio_delay_us=$6
|
|
local run_status=$7
|
|
local run_log=$8
|
|
local artifact_dir=$9
|
|
local output_json=${10}
|
|
|
|
python3 - <<'PY' \
|
|
"${mode}" \
|
|
"${width}" \
|
|
"${height}" \
|
|
"${fps}" \
|
|
"${video_delay_us}" \
|
|
"${audio_delay_us}" \
|
|
"${run_status}" \
|
|
"${run_log}" \
|
|
"${artifact_dir}" \
|
|
"${output_json}" \
|
|
"${LESAVKA_SERVER_RC_REQUIRE_SYNC_PASS}" \
|
|
"${LESAVKA_SERVER_RC_REQUIRE_FRESHNESS_PASS}" \
|
|
"${LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS}" \
|
|
"${LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS}" \
|
|
"${LESAVKA_SERVER_RC_MAX_VIDEO_P95_JITTER_MS}" \
|
|
"${LESAVKA_SERVER_RC_MAX_AUDIO_P95_JITTER_MS}" \
|
|
"${LESAVKA_SERVER_RC_MAX_VIDEO_MISSING_FRAMES}" \
|
|
"${LESAVKA_SERVER_RC_MAX_VIDEO_UNDECODABLE_FRAMES}" \
|
|
"${LESAVKA_SERVER_RC_MAX_VIDEO_DUPLICATE_FRAMES}" \
|
|
"${LESAVKA_SERVER_RC_MAX_AUDIO_LOW_RMS_WINDOWS}" \
|
|
"${LESAVKA_SERVER_RC_MIN_CODED_PAIRS}" \
|
|
"${LESAVKA_SERVER_RC_REQUIRE_ALL_CODED_PAIRS}" \
|
|
"${LESAVKA_SERVER_RC_REQUIRE_SMOOTHNESS_PASS}"
|
|
import json
|
|
import math
|
|
import pathlib
|
|
import sys
|
|
|
|
(
|
|
mode,
|
|
width_raw,
|
|
height_raw,
|
|
fps_raw,
|
|
video_delay_raw,
|
|
audio_delay_raw,
|
|
run_status_raw,
|
|
run_log,
|
|
artifact_dir_raw,
|
|
output_json,
|
|
require_sync_raw,
|
|
require_freshness_raw,
|
|
max_video_hiccups_raw,
|
|
max_audio_hiccups_raw,
|
|
max_video_jitter_raw,
|
|
max_audio_jitter_raw,
|
|
max_missing_raw,
|
|
max_undecodable_raw,
|
|
max_duplicates_raw,
|
|
max_low_rms_raw,
|
|
min_coded_pairs_raw,
|
|
require_all_coded_pairs_raw,
|
|
require_smoothness_raw,
|
|
) = sys.argv[1:]
|
|
|
|
|
|
def as_bool(value):
|
|
return str(value).strip().lower() not in {"", "0", "false", "no", "off"}
|
|
|
|
|
|
def as_int(value, default=0):
|
|
try:
|
|
return int(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def as_float(value, default=0.0):
|
|
try:
|
|
result = float(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
return result if math.isfinite(result) else default
|
|
|
|
|
|
def load_json(path):
|
|
try:
|
|
return json.loads(pathlib.Path(path).read_text())
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def nested(mapping, *keys, default=None):
|
|
value = mapping
|
|
for key in keys:
|
|
if not isinstance(value, dict):
|
|
return default
|
|
value = value.get(key)
|
|
return default if value is None else value
|
|
|
|
|
|
artifact_dir = pathlib.Path(artifact_dir_raw) if artifact_dir_raw else pathlib.Path()
|
|
report = load_json(artifact_dir / "report.json")
|
|
correlation = load_json(artifact_dir / "output-delay-correlation.json")
|
|
calibration = load_json(artifact_dir / "output-delay-calibration.json")
|
|
timing_map = correlation.get("timing_map") or {}
|
|
freshness = correlation.get("freshness") or {}
|
|
capture_timebase = freshness.get("capture_timebase") or {}
|
|
smoothness = correlation.get("smoothness") or {}
|
|
event_visibility = correlation.get("event_visibility") or {}
|
|
video = smoothness.get("video") or {}
|
|
audio = smoothness.get("audio") or {}
|
|
audio_cadence = audio.get("packet_cadence") or {}
|
|
audio_rms = audio.get("rms_continuity") or {}
|
|
verdict = report.get("verdict") or {}
|
|
signature_coverage = report.get("signature_coverage") or {}
|
|
|
|
sync_pass = verdict.get("passed") is True
|
|
freshness_status = freshness.get("status", "unknown")
|
|
freshness_pass = freshness_status == "pass"
|
|
run_status = as_int(run_status_raw, 1)
|
|
|
|
video_hiccups = as_int(video.get("hiccup_count"), 0)
|
|
audio_hiccups = as_int(audio_cadence.get("hiccup_count"), 0)
|
|
video_jitter = as_float(nested(video, "jitter_stats", "p95_jitter_ms"), 0.0)
|
|
audio_jitter = as_float(nested(audio_cadence, "jitter_stats", "p95_jitter_ms"), 0.0)
|
|
missing = as_int(video.get("estimated_missing_frames"), 0)
|
|
undecodable = as_int(video.get("undecodable_frames"), 0)
|
|
duplicates = as_int(video.get("duplicate_frames"), 0)
|
|
low_rms = as_int(audio_rms.get("low_rms_window_count"), 0)
|
|
signature_expected = as_int(signature_coverage.get("expected_event_count"), 0)
|
|
signature_paired = as_int(signature_coverage.get("paired_event_count"), 0)
|
|
signature_unknown = as_int(signature_coverage.get("unknown_pair_identity_count"), 0)
|
|
min_coded_pairs = as_int(min_coded_pairs_raw, 0)
|
|
require_all_coded_pairs = as_bool(require_all_coded_pairs_raw)
|
|
require_smoothness = as_bool(require_smoothness_raw)
|
|
pair_confidences = []
|
|
for event in report.get("paired_events") or []:
|
|
if not isinstance(event, dict):
|
|
continue
|
|
try:
|
|
confidence = float(event.get("confidence"))
|
|
except Exception:
|
|
continue
|
|
if math.isfinite(confidence):
|
|
pair_confidences.append(confidence)
|
|
|
|
|
|
def median(values, default=0.0):
|
|
if not values:
|
|
return default
|
|
ordered = sorted(values)
|
|
mid = len(ordered) // 2
|
|
if len(ordered) % 2:
|
|
return ordered[mid]
|
|
return (ordered[mid - 1] + ordered[mid]) / 2.0
|
|
|
|
|
|
activity_start_delta_ms = as_float(report.get("activity_start_delta_ms"), 0.0)
|
|
first_skew_ms = as_float(report.get("first_skew_ms"), 0.0)
|
|
activity_pair_disagreement_ms = (
|
|
activity_start_delta_ms - first_skew_ms
|
|
if as_int(report.get("paired_event_count"), 0) > 0
|
|
else 0.0
|
|
)
|
|
|
|
reasons = []
|
|
smoothness_warnings = []
|
|
if run_status != 0:
|
|
reasons.append(f"probe command exited {run_status}")
|
|
if as_bool(require_sync_raw) and not sync_pass:
|
|
reasons.append(f"sync did not pass: {verdict.get('status', 'unknown')}")
|
|
if as_bool(require_freshness_raw) and not freshness_pass:
|
|
reasons.append(f"freshness did not pass: {freshness_status}")
|
|
if capture_timebase and capture_timebase.get("valid") is False:
|
|
reasons.append(
|
|
"capture timebase invalid: "
|
|
f"{capture_timebase.get('reason', capture_timebase.get('status', 'invalid'))}"
|
|
)
|
|
if signature_expected > 0:
|
|
if require_all_coded_pairs and signature_paired < signature_expected:
|
|
reasons.append(f"paired coded signatures {signature_paired} < expected {signature_expected}")
|
|
elif min_coded_pairs > 0 and signature_paired < min_coded_pairs:
|
|
reasons.append(f"paired coded signatures {signature_paired} < required {min_coded_pairs}")
|
|
if signature_unknown > 0:
|
|
reasons.append(f"paired signatures without coded identity {signature_unknown} > 0")
|
|
elif report.get("coded_events") is True:
|
|
reasons.append("coded signature coverage unavailable")
|
|
if video_hiccups > as_int(max_video_hiccups_raw):
|
|
smoothness_warnings.append(f"video hiccups {video_hiccups} > {max_video_hiccups_raw}")
|
|
if audio_hiccups > as_int(max_audio_hiccups_raw):
|
|
smoothness_warnings.append(f"audio hiccups {audio_hiccups} > {max_audio_hiccups_raw}")
|
|
if video_jitter > as_float(max_video_jitter_raw):
|
|
smoothness_warnings.append(f"video p95 jitter {video_jitter:.1f}ms > {as_float(max_video_jitter_raw):.1f}ms")
|
|
if audio_jitter > as_float(max_audio_jitter_raw):
|
|
smoothness_warnings.append(f"audio p95 jitter {audio_jitter:.1f}ms > {as_float(max_audio_jitter_raw):.1f}ms")
|
|
if missing > as_int(max_missing_raw):
|
|
smoothness_warnings.append(f"estimated missing video frames {missing} > {max_missing_raw}")
|
|
if undecodable > as_int(max_undecodable_raw):
|
|
smoothness_warnings.append(f"undecodable video frames {undecodable} > {max_undecodable_raw}")
|
|
if duplicates > as_int(max_duplicates_raw):
|
|
smoothness_warnings.append(f"duplicate video frames {duplicates} > {max_duplicates_raw}")
|
|
if low_rms > as_int(max_low_rms_raw):
|
|
smoothness_warnings.append(f"low-RMS audio windows {low_rms} > {max_low_rms_raw}")
|
|
if require_smoothness:
|
|
reasons.extend(smoothness_warnings)
|
|
|
|
artifact = {
|
|
"schema": "lesavka.server-rc-mode-result.v1",
|
|
"mode": mode,
|
|
"width": as_int(width_raw),
|
|
"height": as_int(height_raw),
|
|
"fps": as_int(fps_raw),
|
|
"audio_delay_us": as_int(audio_delay_raw),
|
|
"video_delay_us": as_int(video_delay_raw),
|
|
"run_status": run_status,
|
|
"run_log": run_log,
|
|
"artifact_dir": str(artifact_dir),
|
|
"report_json": str(artifact_dir / "report.json"),
|
|
"correlation_json": str(artifact_dir / "output-delay-correlation.json"),
|
|
"calibration_json": str(artifact_dir / "output-delay-calibration.json"),
|
|
"timing_map": timing_map,
|
|
"passed": not reasons,
|
|
"failure_reasons": reasons,
|
|
"smoothness_required": require_smoothness,
|
|
"smoothness_warnings": smoothness_warnings,
|
|
"event_visibility": event_visibility,
|
|
"sync": {
|
|
"passed": sync_pass,
|
|
"status": verdict.get("status", "unknown"),
|
|
"reason": verdict.get("reason", ""),
|
|
"p95_abs_skew_ms": as_float(verdict.get("p95_abs_skew_ms"), 0.0),
|
|
"median_skew_ms": as_float(report.get("median_skew_ms"), 0.0),
|
|
"drift_ms": as_float(report.get("drift_ms"), 0.0),
|
|
"activity_start_delta_ms": activity_start_delta_ms,
|
|
"first_skew_ms": first_skew_ms,
|
|
"activity_pair_disagreement_ms": activity_pair_disagreement_ms,
|
|
"video_event_count": as_int(report.get("video_event_count"), 0),
|
|
"audio_event_count": as_int(report.get("audio_event_count"), 0),
|
|
"paired_event_count": as_int(report.get("paired_event_count"), 0),
|
|
"signature_expected_event_count": signature_expected,
|
|
"signature_paired_event_count": signature_paired,
|
|
"signature_missing_event_ids": signature_coverage.get("missing_event_ids") or [],
|
|
"signature_missing_codes": signature_coverage.get("missing_codes") or [],
|
|
"signature_unknown_pair_identity_count": signature_unknown,
|
|
"paired_confidence_min": min(pair_confidences) if pair_confidences else 0.0,
|
|
"paired_confidence_median": median(pair_confidences),
|
|
},
|
|
"freshness": {
|
|
"status": freshness_status,
|
|
"reason": freshness.get("reason", ""),
|
|
"worst_event_age_p95_ms": freshness.get("worst_event_age_p95_ms"),
|
|
"worst_p95_pipeline_freshness_ms": freshness.get("worst_p95_pipeline_freshness_ms"),
|
|
"minimum_pipeline_freshness_ms": freshness.get("minimum_pipeline_freshness_ms"),
|
|
"clock_uncertainty_ms": freshness.get("clock_uncertainty_ms"),
|
|
"worst_event_age_with_uncertainty_ms": freshness.get("worst_event_age_with_uncertainty_ms"),
|
|
"worst_freshness_drift_ms": freshness.get("worst_freshness_drift_ms"),
|
|
"max_age_limit_ms": freshness.get("max_age_limit_ms"),
|
|
"capture_timebase_status": capture_timebase.get("status"),
|
|
"capture_timebase_valid": capture_timebase.get("valid"),
|
|
"capture_timebase_reason": capture_timebase.get("reason"),
|
|
},
|
|
"smoothness": {
|
|
"video_frames": as_int(video.get("timestamps"), 0),
|
|
"video_p95_jitter_ms": video_jitter,
|
|
"video_max_interval_ms": as_float(nested(video, "interval_stats", "max_interval_ms"), 0.0),
|
|
"video_hiccups": video_hiccups,
|
|
"video_decoded_frames": as_int(video.get("decoded_frames"), 0),
|
|
"video_duplicate_frames": duplicates,
|
|
"video_estimated_missing_frames": missing,
|
|
"video_undecodable_frames": undecodable,
|
|
"audio_packets": as_int(audio_cadence.get("timestamps"), 0),
|
|
"audio_p95_jitter_ms": audio_jitter,
|
|
"audio_max_interval_ms": as_float(nested(audio_cadence, "interval_stats", "max_interval_ms"), 0.0),
|
|
"audio_hiccups": audio_hiccups,
|
|
"audio_low_rms_windows": low_rms,
|
|
"audio_median_rms": as_float(nested(audio_rms, "rms_stats", "median_rms"), 0.0),
|
|
},
|
|
"output_delay_calibration": {
|
|
"ready": calibration.get("ready") is True,
|
|
"decision": calibration.get("decision", "unknown"),
|
|
"target": calibration.get("target", ""),
|
|
"paired_event_count": as_int(calibration.get("paired_event_count"), 0),
|
|
"min_pairs": as_int(calibration.get("min_pairs"), 0),
|
|
"measured_device_skew_ms": as_float(calibration.get("measured_device_skew_ms"), 0.0),
|
|
"p95_abs_skew_ms": as_float(calibration.get("p95_abs_skew_ms"), 0.0),
|
|
"max_abs_skew_ms": as_float(calibration.get("max_abs_skew_ms"), 0.0),
|
|
"drift_ms": as_float(calibration.get("drift_ms"), 0.0),
|
|
"active_audio_offset_us": as_int(calibration.get("active_audio_offset_us"), as_int(audio_delay_raw)),
|
|
"active_video_offset_us": as_int(calibration.get("active_video_offset_us"), as_int(video_delay_raw)),
|
|
"audio_offset_adjust_us": as_int(calibration.get("audio_offset_adjust_us"), 0),
|
|
"video_offset_adjust_us": as_int(calibration.get("video_offset_adjust_us"), 0),
|
|
"audio_target_offset_us": as_int(calibration.get("audio_target_offset_us"), 0),
|
|
"video_target_offset_us": as_int(calibration.get("video_target_offset_us"), 0),
|
|
"raw_device_delta_us": as_int(calibration.get("raw_device_delta_us"), 0),
|
|
"bounded_device_delta_us": as_int(calibration.get("bounded_device_delta_us"), 0),
|
|
"max_step_us": as_int(calibration.get("max_step_us"), 0),
|
|
"note": calibration.get("note", ""),
|
|
},
|
|
}
|
|
pathlib.Path(output_json).write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n")
|
|
PY
|
|
}
|
|
|
|
annotate_mode_result() {
|
|
local output_json=$1
|
|
local mode_id=$2
|
|
local mode_run_index=$3
|
|
local repeat_index=$4
|
|
local repeat_count=$5
|
|
local matrix_sequence=$6
|
|
local phase=$7
|
|
|
|
python3 - <<'PY' \
|
|
"${output_json}" \
|
|
"${mode_id}" \
|
|
"${mode_run_index}" \
|
|
"${repeat_index}" \
|
|
"${repeat_count}" \
|
|
"${matrix_sequence}" \
|
|
"${phase}"
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
(
|
|
output_json_raw,
|
|
mode_id,
|
|
mode_run_index_raw,
|
|
repeat_index_raw,
|
|
repeat_count_raw,
|
|
matrix_sequence_raw,
|
|
phase,
|
|
) = sys.argv[1:]
|
|
|
|
|
|
def as_int(value, default=0):
|
|
try:
|
|
return int(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
path = pathlib.Path(output_json_raw)
|
|
result = json.loads(path.read_text())
|
|
result.update(
|
|
{
|
|
"mode_id": mode_id,
|
|
"mode_run_index": as_int(mode_run_index_raw),
|
|
"repeat_index": as_int(repeat_index_raw),
|
|
"repeat_count": as_int(repeat_count_raw),
|
|
"matrix_sequence": as_int(matrix_sequence_raw),
|
|
"probe_phase": phase,
|
|
}
|
|
)
|
|
path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
|
|
PY
|
|
}
|
|
|
|
summarize_matrix() {
|
|
python3 - <<'PY' "${MATRIX_REPORT_DIR}" "${MATRIX_SUMMARY_JSON}" "${MATRIX_SUMMARY_CSV}" "${MATRIX_SUMMARY_TXT}" "${MATRIX_DELAY_JSON}" "${MATRIX_DELAY_ENV}" "${MATRIX_STATIC_JSON}" "${MATRIX_STATIC_CSV}" "${MATRIX_STATIC_TXT}" "${MATRIX_STATIC_ENV}" "${LESAVKA_SERVER_RC_STATIC_MIN_RUNS}" "${LESAVKA_SERVER_RC_STATIC_MAX_SPREAD_US}" "${LESAVKA_SERVER_RC_STATIC_MAX_P95_SKEW_MS}" "${LESAVKA_SERVER_RC_STATIC_MAX_MEDIAN_SKEW_MS}" "${LESAVKA_SERVER_RC_STATIC_REQUIRE_FRESHNESS}" "${LESAVKA_SERVER_RC_STATIC_REQUIRE_SMOOTHNESS}"
|
|
import csv
|
|
import json
|
|
import pathlib
|
|
import shlex
|
|
import sys
|
|
|
|
root = pathlib.Path(sys.argv[1])
|
|
summary_json = pathlib.Path(sys.argv[2])
|
|
summary_csv = pathlib.Path(sys.argv[3])
|
|
summary_txt = pathlib.Path(sys.argv[4])
|
|
delay_json = pathlib.Path(sys.argv[5])
|
|
delay_env = pathlib.Path(sys.argv[6])
|
|
static_json = pathlib.Path(sys.argv[7])
|
|
static_csv = pathlib.Path(sys.argv[8])
|
|
static_txt = pathlib.Path(sys.argv[9])
|
|
static_env = pathlib.Path(sys.argv[10])
|
|
static_min_runs = int(sys.argv[11])
|
|
static_max_spread_us = int(sys.argv[12])
|
|
static_max_p95_skew_ms = float(sys.argv[13])
|
|
static_max_median_skew_ms = float(sys.argv[14])
|
|
static_require_freshness = sys.argv[15].strip().lower() not in {"", "0", "false", "no", "off"}
|
|
static_require_smoothness = sys.argv[16].strip().lower() not in {"", "0", "false", "no", "off"}
|
|
|
|
|
|
def as_int(value, default=0):
|
|
try:
|
|
return int(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def as_float(value, default=0.0):
|
|
try:
|
|
return float(str(value).strip())
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def median(values, default=None):
|
|
cleaned = sorted(value for value in values if value is not None)
|
|
if not cleaned:
|
|
return default
|
|
mid = len(cleaned) // 2
|
|
if len(cleaned) % 2:
|
|
return cleaned[mid]
|
|
return (cleaned[mid - 1] + cleaned[mid]) / 2.0
|
|
|
|
|
|
def median_int(values, default=None):
|
|
value = median(values, default)
|
|
if value is None:
|
|
return None
|
|
return int(round(value))
|
|
|
|
|
|
results = []
|
|
for path in sorted(root.glob("*/mode-result.json")):
|
|
try:
|
|
result = json.loads(path.read_text())
|
|
seed_path = path.with_name("mode-result-seed.json")
|
|
tuned_path = path.with_name("mode-result-tuned.json")
|
|
if seed_path.exists():
|
|
result["seed_result_json"] = str(seed_path)
|
|
try:
|
|
seed = json.loads(seed_path.read_text())
|
|
result["seed_audio_delay_us"] = seed.get("audio_delay_us")
|
|
result["seed_video_delay_us"] = seed.get("video_delay_us")
|
|
except Exception:
|
|
pass
|
|
if tuned_path.exists():
|
|
result["tuned_result_json"] = str(tuned_path)
|
|
result["result_json"] = str(path)
|
|
results.append(result)
|
|
except Exception:
|
|
continue
|
|
results.sort(
|
|
key=lambda item: (
|
|
item.get("mode") or "",
|
|
as_int(item.get("mode_run_index"), as_int(item.get("matrix_sequence"), 0)),
|
|
as_int(item.get("matrix_sequence"), 0),
|
|
)
|
|
)
|
|
|
|
mode_groups = {}
|
|
for result in results:
|
|
mode = result.get("mode")
|
|
if not mode:
|
|
continue
|
|
mode_groups.setdefault(mode, []).append(result)
|
|
|
|
|
|
def static_eligibility(result):
|
|
sync = result.get("sync") or {}
|
|
freshness = result.get("freshness") or {}
|
|
calibration = result.get("output_delay_calibration") or {}
|
|
reasons = []
|
|
required_pairs = as_int(calibration.get("min_pairs"), 13) or 13
|
|
if result.get("passed") is not True:
|
|
reasons.append("probe did not pass all required gates")
|
|
if sync.get("passed") is not True:
|
|
reasons.append(f"sync did not pass ({sync.get('status', 'unknown')})")
|
|
if as_int(sync.get("paired_event_count"), 0) < required_pairs:
|
|
reasons.append(f"paired events {sync.get('paired_event_count', 0)} < {required_pairs}")
|
|
if as_float(sync.get("p95_abs_skew_ms"), 0.0) > static_max_p95_skew_ms:
|
|
reasons.append(
|
|
f"p95 skew {as_float(sync.get('p95_abs_skew_ms'), 0.0):.1f}ms > {static_max_p95_skew_ms:.1f}ms"
|
|
)
|
|
if abs(as_float(sync.get("median_skew_ms"), 0.0)) > static_max_median_skew_ms:
|
|
reasons.append(
|
|
f"median skew {as_float(sync.get('median_skew_ms'), 0.0):+.1f}ms outside +/-{static_max_median_skew_ms:.1f}ms"
|
|
)
|
|
if static_require_freshness and freshness.get("status") != "pass":
|
|
reasons.append(f"freshness did not pass ({freshness.get('status', 'unknown')})")
|
|
if static_require_smoothness and result.get("smoothness_warnings"):
|
|
reasons.append("smoothness warnings present")
|
|
if not isinstance(result.get("video_delay_us"), int) or not isinstance(result.get("audio_delay_us"), int):
|
|
reasons.append("tested delay values unavailable")
|
|
return reasons
|
|
|
|
|
|
static_modes = {}
|
|
delay_recommendations = {}
|
|
video_delay_entries = []
|
|
audio_delay_entries = []
|
|
static_video_entries = []
|
|
static_audio_entries = []
|
|
for mode, mode_results in sorted(mode_groups.items()):
|
|
eligible = []
|
|
rejected = []
|
|
for result in mode_results:
|
|
reasons = static_eligibility(result)
|
|
if reasons:
|
|
rejected.append(
|
|
{
|
|
"mode_run_index": result.get("mode_run_index"),
|
|
"artifact_dir": result.get("artifact_dir"),
|
|
"reasons": reasons,
|
|
"tested_video_delay_us": result.get("video_delay_us"),
|
|
"tested_audio_delay_us": result.get("audio_delay_us"),
|
|
"p95_abs_skew_ms": (result.get("sync") or {}).get("p95_abs_skew_ms"),
|
|
"median_skew_ms": (result.get("sync") or {}).get("median_skew_ms"),
|
|
}
|
|
)
|
|
else:
|
|
eligible.append(result)
|
|
|
|
tested_video = [result.get("video_delay_us") for result in eligible]
|
|
tested_audio = [result.get("audio_delay_us") for result in eligible]
|
|
target_video = [
|
|
(result.get("output_delay_calibration") or {}).get("video_target_offset_us")
|
|
for result in eligible
|
|
if isinstance((result.get("output_delay_calibration") or {}).get("video_target_offset_us"), int)
|
|
]
|
|
target_audio = [
|
|
(result.get("output_delay_calibration") or {}).get("audio_target_offset_us")
|
|
for result in eligible
|
|
if isinstance((result.get("output_delay_calibration") or {}).get("audio_target_offset_us"), int)
|
|
]
|
|
tested_video_spread = (max(tested_video) - min(tested_video)) if tested_video else None
|
|
tested_audio_spread = (max(tested_audio) - min(tested_audio)) if tested_audio else None
|
|
target_video_spread = (max(target_video) - min(target_video)) if target_video else None
|
|
recommended_video = median_int(tested_video)
|
|
recommended_audio = median_int(tested_audio)
|
|
status_reasons = []
|
|
if len(eligible) < static_min_runs:
|
|
status_reasons.append(f"eligible runs {len(eligible)} < {static_min_runs}")
|
|
if tested_video_spread is not None and tested_video_spread > static_max_spread_us:
|
|
status_reasons.append(f"tested video delay spread {tested_video_spread}us > {static_max_spread_us}us")
|
|
if tested_audio_spread is not None and tested_audio_spread > static_max_spread_us:
|
|
status_reasons.append(f"tested audio delay spread {tested_audio_spread}us > {static_max_spread_us}us")
|
|
if recommended_video is None or recommended_audio is None:
|
|
status_reasons.append("no recommended delay could be derived")
|
|
if len(eligible) < static_min_runs:
|
|
status = "needs_more_runs"
|
|
elif status_reasons:
|
|
status = "unstable"
|
|
else:
|
|
status = "ready"
|
|
|
|
p95_values = [as_float((result.get("sync") or {}).get("p95_abs_skew_ms"), 0.0) for result in eligible]
|
|
median_skews = [as_float((result.get("sync") or {}).get("median_skew_ms"), 0.0) for result in eligible]
|
|
freshness_budgets = [
|
|
as_float((result.get("freshness") or {}).get("worst_event_age_with_uncertainty_ms"), 0.0)
|
|
for result in eligible
|
|
]
|
|
static_entry = {
|
|
"status": status,
|
|
"ready": status == "ready",
|
|
"reasons": status_reasons,
|
|
"mode": mode,
|
|
"total_runs": len(mode_results),
|
|
"eligible_runs": len(eligible),
|
|
"rejected_runs": rejected,
|
|
"recommended_video_delay_us": recommended_video,
|
|
"recommended_audio_delay_us": recommended_audio,
|
|
"tested_video_delay_us": tested_video,
|
|
"tested_audio_delay_us": tested_audio,
|
|
"tested_video_delay_min_us": min(tested_video) if tested_video else None,
|
|
"tested_video_delay_max_us": max(tested_video) if tested_video else None,
|
|
"tested_video_delay_spread_us": tested_video_spread,
|
|
"tested_audio_delay_spread_us": tested_audio_spread,
|
|
"target_video_delay_us": target_video,
|
|
"target_audio_delay_us": target_audio,
|
|
"target_video_delay_median_us": median_int(target_video),
|
|
"target_audio_delay_median_us": median_int(target_audio),
|
|
"target_video_delay_spread_us": target_video_spread,
|
|
"sync_p95_abs_skew_ms_max": max(p95_values) if p95_values else None,
|
|
"sync_p95_abs_skew_ms_median": median(p95_values),
|
|
"sync_abs_median_skew_ms_max": max((abs(value) for value in median_skews), default=None),
|
|
"freshness_budget_ms_max": max(freshness_budgets) if freshness_budgets else None,
|
|
"eligible_artifact_dirs": [result.get("artifact_dir") for result in eligible],
|
|
"eligible_result_json": [result.get("result_json") for result in eligible],
|
|
}
|
|
static_modes[mode] = static_entry
|
|
|
|
if status == "ready" and isinstance(recommended_video, int) and isinstance(recommended_audio, int):
|
|
static_video_entries.append(f"{mode}={recommended_video}")
|
|
static_audio_entries.append(f"{mode}={recommended_audio}")
|
|
|
|
delay_video = recommended_video
|
|
delay_audio = recommended_audio
|
|
recommendation_status = "static_ready" if status == "ready" else status
|
|
if status != "ready":
|
|
fallback = (eligible or mode_results)[-1]
|
|
fallback_calibration = fallback.get("output_delay_calibration") or {}
|
|
fallback_video_target = fallback_calibration.get("video_target_offset_us")
|
|
fallback_audio_target = fallback_calibration.get("audio_target_offset_us")
|
|
if not isinstance(delay_video, int) or not isinstance(delay_audio, int):
|
|
if (
|
|
fallback_calibration.get("ready") is True
|
|
and isinstance(fallback_video_target, int)
|
|
and isinstance(fallback_audio_target, int)
|
|
):
|
|
delay_video = fallback_video_target
|
|
delay_audio = fallback_audio_target
|
|
recommendation_status = "candidate_unconfirmed"
|
|
else:
|
|
delay_video = fallback.get("video_delay_us")
|
|
delay_audio = fallback.get("audio_delay_us")
|
|
recommendation_status = "tested_unstable"
|
|
else:
|
|
recommendation_status = f"candidate_{status}"
|
|
if isinstance(delay_video, int) and isinstance(delay_audio, int):
|
|
video_delay_entries.append(f"{mode}={delay_video}")
|
|
audio_delay_entries.append(f"{mode}={delay_audio}")
|
|
delay_recommendations[mode] = {
|
|
"status": recommendation_status,
|
|
"static_status": status,
|
|
"audio_delay_us": delay_audio,
|
|
"video_delay_us": delay_video,
|
|
"eligible_runs": len(eligible),
|
|
"total_runs": len(mode_results),
|
|
"tested_video_delay_spread_us": tested_video_spread,
|
|
"sync_p95_abs_skew_ms_max": static_entry["sync_p95_abs_skew_ms_max"],
|
|
"sync_abs_median_skew_ms_max": static_entry["sync_abs_median_skew_ms_max"],
|
|
"freshness_budget_ms_max": static_entry["freshness_budget_ms_max"],
|
|
"reasons": status_reasons,
|
|
}
|
|
|
|
summary = {
|
|
"schema": "lesavka.server-rc-mode-matrix-summary.v1",
|
|
"artifact_dir": str(root),
|
|
"passed": bool(results) and all(result.get("passed") for result in results),
|
|
"mode_count": len(results),
|
|
"delay_recommendations_json": str(delay_json),
|
|
"delay_recommendations_env": str(delay_env),
|
|
"static_calibration_json": str(static_json),
|
|
"static_calibration_csv": str(static_csv),
|
|
"static_calibration_txt": str(static_txt),
|
|
"static_calibration_env": str(static_env),
|
|
"static_ready": bool(static_modes) and all(entry.get("ready") for entry in static_modes.values()),
|
|
"static_calibration": static_modes,
|
|
"delay_recommendations": delay_recommendations,
|
|
"results": results,
|
|
}
|
|
summary_json.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
|
delay_json.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema": "lesavka.server-rc-mode-delay-recommendations.v1",
|
|
"artifact_dir": str(root),
|
|
"video_delays_us": {
|
|
mode: entry.get("video_delay_us")
|
|
for mode, entry in delay_recommendations.items()
|
|
},
|
|
"audio_delays_us": {
|
|
mode: entry.get("audio_delay_us")
|
|
for mode, entry in delay_recommendations.items()
|
|
},
|
|
"recommendations": delay_recommendations,
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n"
|
|
)
|
|
static_json.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema": "lesavka.server-rc-static-calibration.v1",
|
|
"artifact_dir": str(root),
|
|
"ready": summary["static_ready"],
|
|
"criteria": {
|
|
"min_runs": static_min_runs,
|
|
"max_spread_us": static_max_spread_us,
|
|
"max_p95_skew_ms": static_max_p95_skew_ms,
|
|
"max_median_skew_ms": static_max_median_skew_ms,
|
|
"require_freshness": static_require_freshness,
|
|
"require_smoothness": static_require_smoothness,
|
|
},
|
|
"video_delays_us": {
|
|
mode: entry.get("recommended_video_delay_us")
|
|
for mode, entry in static_modes.items()
|
|
if entry.get("ready")
|
|
},
|
|
"audio_delays_us": {
|
|
mode: entry.get("recommended_audio_delay_us")
|
|
for mode, entry in static_modes.items()
|
|
if entry.get("ready")
|
|
},
|
|
"modes": static_modes,
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n"
|
|
)
|
|
delay_env.write_text(
|
|
"LESAVKA_SERVER_RC_MODE_DELAYS_US="
|
|
+ shlex.quote(",".join(video_delay_entries))
|
|
+ "\n"
|
|
+ "LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US="
|
|
+ shlex.quote(",".join(audio_delay_entries))
|
|
+ "\n"
|
|
)
|
|
static_env.write_text(
|
|
"LESAVKA_SERVER_RC_MODE_DELAYS_US="
|
|
+ shlex.quote(",".join(static_video_entries))
|
|
+ "\n"
|
|
+ "LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US="
|
|
+ shlex.quote(",".join(static_audio_entries))
|
|
+ "\n"
|
|
)
|
|
|
|
fieldnames = [
|
|
"mode",
|
|
"mode_run_index",
|
|
"repeat_index",
|
|
"matrix_sequence",
|
|
"passed",
|
|
"seed_video_delay_us",
|
|
"seed_audio_delay_us",
|
|
"video_delay_us",
|
|
"audio_delay_us",
|
|
"sync_status",
|
|
"p95_abs_skew_ms",
|
|
"median_skew_ms",
|
|
"sync_drift_ms",
|
|
"freshness_status",
|
|
"freshness_budget_ms",
|
|
"freshness_drift_ms",
|
|
"capture_timebase_status",
|
|
"video_hiccups",
|
|
"video_missing",
|
|
"video_undecodable",
|
|
"audio_hiccups",
|
|
"audio_low_rms",
|
|
"calibration_ready",
|
|
"calibration_decision",
|
|
"calibration_target",
|
|
"calibration_measured_skew_ms",
|
|
"calibration_video_target_offset_us",
|
|
"calibration_audio_target_offset_us",
|
|
"artifact_dir",
|
|
"result_json",
|
|
]
|
|
with summary_csv.open("w", newline="", encoding="utf-8") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
for result in results:
|
|
writer.writerow({
|
|
"mode": result.get("mode"),
|
|
"mode_run_index": result.get("mode_run_index"),
|
|
"repeat_index": result.get("repeat_index"),
|
|
"matrix_sequence": result.get("matrix_sequence"),
|
|
"passed": result.get("passed"),
|
|
"seed_video_delay_us": result.get("seed_video_delay_us"),
|
|
"seed_audio_delay_us": result.get("seed_audio_delay_us"),
|
|
"video_delay_us": result.get("video_delay_us"),
|
|
"audio_delay_us": result.get("audio_delay_us"),
|
|
"sync_status": (result.get("sync") or {}).get("status"),
|
|
"p95_abs_skew_ms": (result.get("sync") or {}).get("p95_abs_skew_ms"),
|
|
"median_skew_ms": (result.get("sync") or {}).get("median_skew_ms"),
|
|
"sync_drift_ms": (result.get("sync") or {}).get("drift_ms"),
|
|
"freshness_status": (result.get("freshness") or {}).get("status"),
|
|
"freshness_budget_ms": (result.get("freshness") or {}).get("worst_event_age_with_uncertainty_ms"),
|
|
"freshness_drift_ms": (result.get("freshness") or {}).get("worst_freshness_drift_ms"),
|
|
"capture_timebase_status": (result.get("freshness") or {}).get("capture_timebase_status"),
|
|
"video_hiccups": (result.get("smoothness") or {}).get("video_hiccups"),
|
|
"video_missing": (result.get("smoothness") or {}).get("video_estimated_missing_frames"),
|
|
"video_undecodable": (result.get("smoothness") or {}).get("video_undecodable_frames"),
|
|
"audio_hiccups": (result.get("smoothness") or {}).get("audio_hiccups"),
|
|
"audio_low_rms": (result.get("smoothness") or {}).get("audio_low_rms_windows"),
|
|
"calibration_ready": (result.get("output_delay_calibration") or {}).get("ready"),
|
|
"calibration_decision": (result.get("output_delay_calibration") or {}).get("decision"),
|
|
"calibration_target": (result.get("output_delay_calibration") or {}).get("target"),
|
|
"calibration_measured_skew_ms": (result.get("output_delay_calibration") or {}).get("measured_device_skew_ms"),
|
|
"calibration_video_target_offset_us": (result.get("output_delay_calibration") or {}).get("video_target_offset_us"),
|
|
"calibration_audio_target_offset_us": (result.get("output_delay_calibration") or {}).get("audio_target_offset_us"),
|
|
"artifact_dir": result.get("artifact_dir"),
|
|
"result_json": result.get("result_json"),
|
|
})
|
|
|
|
static_fieldnames = [
|
|
"mode",
|
|
"status",
|
|
"ready",
|
|
"eligible_runs",
|
|
"total_runs",
|
|
"recommended_video_delay_us",
|
|
"recommended_audio_delay_us",
|
|
"tested_video_delay_min_us",
|
|
"tested_video_delay_max_us",
|
|
"tested_video_delay_spread_us",
|
|
"target_video_delay_median_us",
|
|
"target_video_delay_spread_us",
|
|
"sync_p95_abs_skew_ms_max",
|
|
"sync_p95_abs_skew_ms_median",
|
|
"sync_abs_median_skew_ms_max",
|
|
"freshness_budget_ms_max",
|
|
"reasons",
|
|
]
|
|
with static_csv.open("w", newline="", encoding="utf-8") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=static_fieldnames)
|
|
writer.writeheader()
|
|
for mode, entry in sorted(static_modes.items()):
|
|
writer.writerow(
|
|
{
|
|
"mode": mode,
|
|
"status": entry.get("status"),
|
|
"ready": entry.get("ready"),
|
|
"eligible_runs": entry.get("eligible_runs"),
|
|
"total_runs": entry.get("total_runs"),
|
|
"recommended_video_delay_us": entry.get("recommended_video_delay_us"),
|
|
"recommended_audio_delay_us": entry.get("recommended_audio_delay_us"),
|
|
"tested_video_delay_min_us": entry.get("tested_video_delay_min_us"),
|
|
"tested_video_delay_max_us": entry.get("tested_video_delay_max_us"),
|
|
"tested_video_delay_spread_us": entry.get("tested_video_delay_spread_us"),
|
|
"target_video_delay_median_us": entry.get("target_video_delay_median_us"),
|
|
"target_video_delay_spread_us": entry.get("target_video_delay_spread_us"),
|
|
"sync_p95_abs_skew_ms_max": entry.get("sync_p95_abs_skew_ms_max"),
|
|
"sync_p95_abs_skew_ms_median": entry.get("sync_p95_abs_skew_ms_median"),
|
|
"sync_abs_median_skew_ms_max": entry.get("sync_abs_median_skew_ms_max"),
|
|
"freshness_budget_ms_max": entry.get("freshness_budget_ms_max"),
|
|
"reasons": "; ".join(entry.get("reasons") or []),
|
|
}
|
|
)
|
|
|
|
lines = [
|
|
f"Server-to-RC mode matrix for {root}",
|
|
f"- runs: {len(results)}",
|
|
f"- unique modes: {len(mode_groups)}",
|
|
f"- verdict: {'pass' if summary['passed'] else 'fail'}",
|
|
f"- static calibration: {'ready' if summary['static_ready'] else 'needs-more-data'}",
|
|
]
|
|
for result in results:
|
|
sync = result.get("sync") or {}
|
|
freshness = result.get("freshness") or {}
|
|
smooth = result.get("smoothness") or {}
|
|
calibration = result.get("output_delay_calibration") or {}
|
|
marker = "PASS" if result.get("passed") else "FAIL"
|
|
lines.append(
|
|
f"- {marker} {result.get('mode')} run={result.get('mode_run_index', '?')}: "
|
|
f"delays video={result.get('video_delay_us', 0)}us audio={result.get('audio_delay_us', 0)}us; "
|
|
f"sync {sync.get('status')} p95={sync.get('p95_abs_skew_ms', 0.0):.1f}ms median={sync.get('median_skew_ms', 0.0):+.1f}ms; "
|
|
f"freshness {freshness.get('status')} budget={freshness.get('worst_event_age_with_uncertainty_ms') or 0.0:.1f}ms; "
|
|
f"smooth video hiccups={smooth.get('video_hiccups', 0)} missing={smooth.get('video_estimated_missing_frames', 0)} undecodable={smooth.get('video_undecodable_frames', 0)} audio hiccups={smooth.get('audio_hiccups', 0)}"
|
|
)
|
|
lines.append(
|
|
" calibration: "
|
|
f"{calibration.get('decision', 'unknown')} ready={calibration.get('ready', False)} "
|
|
f"target={calibration.get('target', '')} measured={calibration.get('measured_device_skew_ms', 0.0):+.1f}ms "
|
|
f"video_target={calibration.get('video_target_offset_us', 0)}us "
|
|
f"audio_target={calibration.get('audio_target_offset_us', 0)}us"
|
|
)
|
|
lines.append(
|
|
" sync evidence: "
|
|
f"video_onsets={sync.get('video_event_count', 0)} audio_onsets={sync.get('audio_event_count', 0)} "
|
|
f"pairs={sync.get('paired_event_count', 0)} "
|
|
f"coded_pairs={sync.get('signature_paired_event_count', 0)}/{sync.get('signature_expected_event_count', 0)} "
|
|
f"missing_codes={sync.get('signature_missing_codes') or []} "
|
|
f"unknown_identity={sync.get('signature_unknown_pair_identity_count', 0)} "
|
|
f"pair_conf_median={sync.get('paired_confidence_median', 0.0):.3f} "
|
|
f"raw_pair_disagreement={sync.get('activity_pair_disagreement_ms', 0.0):+.1f}ms"
|
|
)
|
|
visibility = result.get("event_visibility") or {}
|
|
visibility_summary = visibility.get("summary") or {}
|
|
if visibility_summary:
|
|
lines.append(
|
|
" coded visibility: "
|
|
f"paired={visibility_summary.get('paired', 0)} "
|
|
f"video_only={visibility_summary.get('video_only', 0)} "
|
|
f"audio_only={visibility_summary.get('audio_only', 0)} "
|
|
f"unpaired={visibility_summary.get('unpaired', 0)} "
|
|
f"missing={visibility_summary.get('missing', 0)}"
|
|
)
|
|
if freshness.get("capture_timebase_status"):
|
|
lines.append(
|
|
" capture timing: "
|
|
f"{freshness.get('capture_timebase_status')} "
|
|
f"valid={freshness.get('capture_timebase_valid')} "
|
|
f"reason={freshness.get('capture_timebase_reason', '')}"
|
|
)
|
|
signal_readiness = result.get("signal_readiness") or {}
|
|
signal_attempts = signal_readiness.get("attempts") or []
|
|
if signal_readiness:
|
|
lines.append(
|
|
" signal readiness: "
|
|
f"passed={signal_readiness.get('passed', False)} "
|
|
f"attempts={len(signal_attempts)} "
|
|
f"reason={signal_readiness.get('reason', '')}"
|
|
)
|
|
for attempt in signal_attempts:
|
|
counts = attempt.get("counts") or {}
|
|
layers = attempt.get("layers") or {}
|
|
lines.append(
|
|
" signal attempt "
|
|
f"{attempt.get('attempt', '?')}: passed={attempt.get('passed', False)} "
|
|
f"server_events={counts.get('server_events', 0)} "
|
|
f"video={counts.get('video_events', 0)} audio={counts.get('audio_events', 0)} "
|
|
f"paired={counts.get('paired_events', counts.get('coded_pairs', 0))} "
|
|
f"audio_ready={layers.get('tethys_audio_ready', False)} "
|
|
f"video_ready={layers.get('tethys_video_ready', False)} "
|
|
f"reason={attempt.get('reason', '')}"
|
|
)
|
|
if "seed_video_delay_us" in result or "seed_audio_delay_us" in result:
|
|
lines.append(
|
|
" tuning: "
|
|
f"seed video={result.get('seed_video_delay_us', 0)}us audio={result.get('seed_audio_delay_us', 0)}us -> "
|
|
f"tested video={result.get('video_delay_us', 0)}us audio={result.get('audio_delay_us', 0)}us"
|
|
)
|
|
for reason in result.get("failure_reasons") or []:
|
|
lines.append(f" reason: {reason}")
|
|
for warning in result.get("smoothness_warnings") or []:
|
|
lines.append(f" smoothness warning: {warning}")
|
|
if video_delay_entries or audio_delay_entries:
|
|
lines.append(f"- recommended video delays: {','.join(video_delay_entries)}")
|
|
lines.append(f"- recommended audio delays: {','.join(audio_delay_entries)}")
|
|
static_lines = [
|
|
f"Server-to-RC static calibration for {root}",
|
|
f"- verdict: {'ready' if summary['static_ready'] else 'needs-more-data'}",
|
|
(
|
|
"- criteria: "
|
|
f"min_runs={static_min_runs} "
|
|
f"max_spread_us={static_max_spread_us} "
|
|
f"max_p95_skew_ms={static_max_p95_skew_ms:.1f} "
|
|
f"max_median_skew_ms={static_max_median_skew_ms:.1f} "
|
|
f"require_freshness={static_require_freshness} "
|
|
f"require_smoothness={static_require_smoothness}"
|
|
),
|
|
]
|
|
for mode, entry in sorted(static_modes.items()):
|
|
marker = "READY" if entry.get("ready") else "HOLD"
|
|
reasons = "; ".join(entry.get("reasons") or [])
|
|
if not reasons and entry.get("ready"):
|
|
reasons = "all stability criteria met"
|
|
static_lines.append(
|
|
f"- {marker} {mode}: "
|
|
f"runs={entry.get('eligible_runs')}/{entry.get('total_runs')} "
|
|
f"video={entry.get('recommended_video_delay_us')}us "
|
|
f"audio={entry.get('recommended_audio_delay_us')}us "
|
|
f"spread={entry.get('tested_video_delay_spread_us')}us "
|
|
f"p95_max={entry.get('sync_p95_abs_skew_ms_max') or 0.0:.1f}ms "
|
|
f"median_abs_max={entry.get('sync_abs_median_skew_ms_max') or 0.0:.1f}ms "
|
|
f"freshness_budget_max={entry.get('freshness_budget_ms_max') or 0.0:.1f}ms"
|
|
)
|
|
static_lines.append(f" reason: {reasons}")
|
|
if static_video_entries or static_audio_entries:
|
|
static_lines.append(f"- static video delays: {','.join(static_video_entries)}")
|
|
static_lines.append(f"- static audio delays: {','.join(static_audio_entries)}")
|
|
lines.extend(["", *static_lines])
|
|
summary_txt.write_text("\n".join(lines) + "\n")
|
|
static_txt.write_text("\n".join(static_lines) + "\n")
|
|
print("\n".join(lines))
|
|
PY
|
|
}
|
|
|
|
if [[ "${LESAVKA_SERVER_RC_MODES}" == "auto" ]]; then
|
|
echo "==> discovering local webcam-backed Lesavka modes"
|
|
LESAVKA_SERVER_RC_MODES="$(discover_local_webcam_modes)"
|
|
LESAVKA_SERVER_RC_MODE_SOURCE=local-v4l2
|
|
fi
|
|
|
|
echo "==> server-to-RC mode matrix"
|
|
echo " ↪ modes=${LESAVKA_SERVER_RC_MODES}"
|
|
echo " ↪ mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}"
|
|
echo " ↪ repeat_count=${LESAVKA_SERVER_RC_REPEAT_COUNT} verbose_probes=${LESAVKA_SERVER_RC_VERBOSE_PROBES}"
|
|
echo " ↪ video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}"
|
|
echo " ↪ audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}"
|
|
echo " ↪ capture_stack=${REMOTE_CAPTURE_STACK} audio_source=${REMOTE_AUDIO_SOURCE} pulse_tool=${REMOTE_PULSE_CAPTURE_TOOL} video_mode=${REMOTE_PULSE_VIDEO_MODE}"
|
|
echo " ↪ tune_delays=${LESAVKA_SERVER_RC_TUNE_DELAYS} confirm=${LESAVKA_SERVER_RC_TUNE_CONFIRM} min_pairs=${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS} max_abs_skew_ms=${LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS} max_step_us=${LESAVKA_SERVER_RC_TUNE_MAX_STEP_US} min_change_us=${LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US}"
|
|
echo " ↪ static_min_runs=${LESAVKA_SERVER_RC_STATIC_MIN_RUNS} static_max_spread_us=${LESAVKA_SERVER_RC_STATIC_MAX_SPREAD_US} static_max_p95_skew_ms=${LESAVKA_SERVER_RC_STATIC_MAX_P95_SKEW_MS} static_max_median_skew_ms=${LESAVKA_SERVER_RC_STATIC_MAX_MEDIAN_SKEW_MS}"
|
|
echo " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS} min_pairs=${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}"
|
|
echo " ↪ coded_pairs_min=${LESAVKA_SERVER_RC_MIN_CODED_PAIRS} require_all_coded=${LESAVKA_SERVER_RC_REQUIRE_ALL_CODED_PAIRS} smoothness_gate=${LESAVKA_SERVER_RC_REQUIRE_SMOOTHNESS_PASS}"
|
|
echo " ↪ signal_ready=${LESAVKA_SERVER_RC_SIGNAL_READY} mode=${LESAVKA_SERVER_RC_SIGNAL_READY_MODE} attempts=${LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS} min_pairs=${LESAVKA_SERVER_RC_SIGNAL_READY_MIN_PAIRS} duration=${LESAVKA_SERVER_RC_SIGNAL_READY_DURATION_SECONDS}s warmup=${LESAVKA_SERVER_RC_SIGNAL_READY_WARMUP_SECONDS}s retry_delay=${LESAVKA_SERVER_RC_SIGNAL_READY_RETRY_DELAY_SECONDS}s"
|
|
echo " ↪ signal_conditioning=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_SECONDS}s warmup=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_WARMUP_SECONDS}s gap=${LESAVKA_SERVER_RC_SIGNAL_CONDITION_GAP_SECONDS}s analysis_window=${LESAVKA_SERVER_RC_ANALYSIS_TIMELINE_WINDOW}"
|
|
echo " ↪ reconfigure=${LESAVKA_SERVER_RC_RECONFIGURE} strategy=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY} allow_gadget_reset=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}"
|
|
echo " ↪ tethys_ready=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY} settle=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}s timeout=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}s preroll_discard=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}s"
|
|
echo " ↪ start_delay=${LESAVKA_SERVER_RC_START_DELAY_SECONDS}s"
|
|
echo " ↪ artifact_dir=${MATRIX_REPORT_DIR}"
|
|
echo " ↪ matrix_run_log=${MATRIX_RUN_LOG}"
|
|
|
|
prime_remote_sudo
|
|
sleep_start_delay
|
|
prebuild_probe_tools
|
|
|
|
if ! [[ "${LESAVKA_SERVER_RC_REPEAT_COUNT}" =~ ^[0-9]+$ ]] || (( LESAVKA_SERVER_RC_REPEAT_COUNT < 1 )); then
|
|
printf 'LESAVKA_SERVER_RC_REPEAT_COUNT must be a positive integer; got %s\n' "${LESAVKA_SERVER_RC_REPEAT_COUNT}" >&2
|
|
exit 64
|
|
fi
|
|
|
|
declare -A mode_run_counts=()
|
|
matrix_sequence=0
|
|
IFS=',' read -r -a modes <<<"${LESAVKA_SERVER_RC_MODES}"
|
|
for mode in "${modes[@]}"; do
|
|
mode="${mode//[[:space:]]/}"
|
|
[[ -n "${mode}" ]] || continue
|
|
read -r width height fps < <(parse_mode "${mode}")
|
|
|
|
for repeat_index in $(seq 1 "${LESAVKA_SERVER_RC_REPEAT_COUNT}"); do
|
|
mode_run_counts["${mode}"]=$(( ${mode_run_counts["${mode}"]:-0} + 1 ))
|
|
mode_run_index=${mode_run_counts["${mode}"]}
|
|
matrix_sequence=$((matrix_sequence + 1))
|
|
video_delay_us="$(lookup_video_delay_us "${mode}")"
|
|
audio_delay_us="$(lookup_audio_delay_us "${mode}")"
|
|
id_base="$(mode_id "${mode}")"
|
|
run_label="$(printf '%02d' "${mode_run_index}")"
|
|
id="${id_base}__run${run_label}"
|
|
mode_dir="${MATRIX_REPORT_DIR}/${id}"
|
|
mode_log="${mode_dir}/mode-run.log"
|
|
mode_result="${mode_dir}/mode-result.json"
|
|
seed_result="${mode_dir}/mode-result-seed.json"
|
|
readiness_dir="${mode_dir}/signal-readiness"
|
|
readiness_log="${readiness_dir}/signal-readiness-run.log"
|
|
readiness_attempts_json="${readiness_dir}/signal-readiness-attempts.json"
|
|
tuned_log="${mode_dir}/mode-tuned-run.log"
|
|
tuned_result="${mode_dir}/mode-result-tuned.json"
|
|
tune_env="${mode_dir}/mode-tune-candidate.env"
|
|
mkdir -p "${mode_dir}"
|
|
|
|
echo "==> mode ${mode} run ${mode_run_index} repeat ${repeat_index}/${LESAVKA_SERVER_RC_REPEAT_COUNT}: video_delay_us=${video_delay_us} audio_delay_us=${audio_delay_us}"
|
|
reconfigure_server_mode "${mode}" "${width}" "${height}" "${fps}" "${audio_delay_us}" "${video_delay_us}"
|
|
wait_tethys_media_ready "${mode}" "${width}" "${height}" "${fps}"
|
|
|
|
if [[ "${LESAVKA_SERVER_RC_SIGNAL_READY}" != "0" && "${LESAVKA_SERVER_RC_SIGNAL_READY_MODE}" != "separate" ]]; then
|
|
echo "==> mode ${mode} run ${mode_run_index}: using same-capture signal conditioning before measured probe"
|
|
fi
|
|
|
|
if [[ "${LESAVKA_SERVER_RC_SIGNAL_READY}" != "0" && "${LESAVKA_SERVER_RC_SIGNAL_READY_MODE}" == "separate" ]]; then
|
|
mkdir -p "${readiness_dir}"
|
|
echo "==> mode ${mode} run ${mode_run_index}: proving Tethys signal readiness before measured probe"
|
|
readiness_pass=1
|
|
readiness_status=0
|
|
readiness_artifact_dir=""
|
|
readiness_reason="no readiness attempts ran"
|
|
readiness_attempt_jsons=()
|
|
for readiness_attempt in $(seq 1 "${LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS}"); do
|
|
attempt_dir="${readiness_dir}/attempt-${readiness_attempt}"
|
|
readiness_log="${attempt_dir}/signal-readiness-run.log"
|
|
readiness_attempt_json="${attempt_dir}/signal-readiness-attempt.json"
|
|
mkdir -p "${attempt_dir}"
|
|
echo " ↪ readiness attempt ${readiness_attempt}/${LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS}: requiring ${LESAVKA_SERVER_RC_SIGNAL_READY_MIN_PAIRS} paired coded events"
|
|
run_mode_probe \
|
|
"${width}" \
|
|
"${height}" \
|
|
"${fps}" \
|
|
"${audio_delay_us}" \
|
|
"${video_delay_us}" \
|
|
"${attempt_dir}" \
|
|
"${readiness_log}" \
|
|
"${LESAVKA_SERVER_RC_SIGNAL_READY_DURATION_SECONDS}" \
|
|
"${LESAVKA_SERVER_RC_SIGNAL_READY_WARMUP_SECONDS}" \
|
|
"${LESAVKA_SERVER_RC_SIGNAL_READY_MIN_PAIRS}" \
|
|
0
|
|
readiness_status=${RUN_MODE_PROBE_STATUS}
|
|
readiness_artifact_dir="$(artifact_dir_from_log "${readiness_log}" "${attempt_dir}")"
|
|
set +e
|
|
readiness_reason="$(
|
|
write_signal_readiness_attempt_result \
|
|
"${readiness_attempt}" \
|
|
"${readiness_artifact_dir}" \
|
|
"${readiness_status}" \
|
|
"${readiness_log}" \
|
|
"${LESAVKA_SERVER_RC_SIGNAL_READY_MIN_PAIRS}" \
|
|
"${readiness_attempt_json}" 2>&1
|
|
)"
|
|
readiness_pass=$?
|
|
set -e
|
|
readiness_attempt_jsons+=("${readiness_attempt_json}")
|
|
if [[ "${readiness_pass}" -eq 0 ]]; then
|
|
echo " ↪ readiness attempt ${readiness_attempt} passed: ${readiness_reason}"
|
|
break
|
|
fi
|
|
[[ -n "${readiness_reason}" ]] || readiness_reason="signal readiness failed"
|
|
echo " ↪ readiness attempt ${readiness_attempt} failed: ${readiness_reason}"
|
|
if [[ "${readiness_attempt}" -lt "${LESAVKA_SERVER_RC_SIGNAL_READY_ATTEMPTS}" ]]; then
|
|
echo " ↪ waiting ${LESAVKA_SERVER_RC_SIGNAL_READY_RETRY_DELAY_SECONDS}s before retrying signal readiness"
|
|
sleep "${LESAVKA_SERVER_RC_SIGNAL_READY_RETRY_DELAY_SECONDS}"
|
|
fi
|
|
done
|
|
write_signal_readiness_attempts_summary "${readiness_attempts_json}" "${readiness_attempt_jsons[@]}"
|
|
if [[ "${readiness_pass}" -ne 0 ]]; then
|
|
[[ -n "${readiness_reason}" ]] || readiness_reason="signal readiness failed"
|
|
echo " ↪ signal readiness failed: ${readiness_reason}"
|
|
write_signal_readiness_failure \
|
|
"${mode}" \
|
|
"${width}" \
|
|
"${height}" \
|
|
"${fps}" \
|
|
"${video_delay_us}" \
|
|
"${audio_delay_us}" \
|
|
"${readiness_status}" \
|
|
"${readiness_log}" \
|
|
"${readiness_artifact_dir}" \
|
|
"${readiness_attempts_json}" \
|
|
"${readiness_reason}" \
|
|
"${mode_result}"
|
|
annotate_mode_result "${mode_result}" "${id_base}" "${mode_run_index}" "${repeat_index}" "${LESAVKA_SERVER_RC_REPEAT_COUNT}" "${matrix_sequence}" "readiness-failure"
|
|
cp "${mode_result}" "${seed_result}"
|
|
if [[ "${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL}" == "0" ]]; then
|
|
break 2
|
|
fi
|
|
continue
|
|
fi
|
|
echo " ↪ signal readiness passed: artifact_dir=${readiness_artifact_dir}"
|
|
fi
|
|
|
|
echo "==> mode ${mode} run ${mode_run_index}: running seed probe"
|
|
run_mode_probe "${width}" "${height}" "${fps}" "${audio_delay_us}" "${video_delay_us}" "${mode_dir}" "${mode_log}"
|
|
run_status=${RUN_MODE_PROBE_STATUS}
|
|
artifact_dir="$(artifact_dir_from_log "${mode_log}" "${mode_dir}")"
|
|
write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${video_delay_us}" "${audio_delay_us}" "${run_status}" "${mode_log}" "${artifact_dir}" "${mode_result}"
|
|
annotate_mode_result "${mode_result}" "${id_base}" "${mode_run_index}" "${repeat_index}" "${LESAVKA_SERVER_RC_REPEAT_COUNT}" "${matrix_sequence}" "seed"
|
|
cp "${mode_result}" "${seed_result}"
|
|
|
|
if [[ "${LESAVKA_SERVER_RC_TUNE_DELAYS}" != "0" && "${LESAVKA_SERVER_RC_TUNE_CONFIRM}" != "0" ]]; then
|
|
write_tune_candidate_env "${seed_result}" "${tune_env}"
|
|
# shellcheck disable=SC1090
|
|
source "${tune_env}"
|
|
if [[ "${tune_ready:-false}" == "true" ]]; then
|
|
echo "==> mode ${mode} run ${mode_run_index}: confirming tuned delays video_delay_us=${tune_video_delay_us} audio_delay_us=${tune_audio_delay_us}"
|
|
echo " ↪ tune delta: video=${tune_video_delta_us}us audio=${tune_audio_delta_us}us pairs=${tune_paired_event_count} drift=${tune_drift_ms}ms"
|
|
run_mode_probe "${width}" "${height}" "${fps}" "${tune_audio_delay_us}" "${tune_video_delay_us}" "${mode_dir}" "${tuned_log}"
|
|
tuned_status=${RUN_MODE_PROBE_STATUS}
|
|
tuned_artifact_dir="$(artifact_dir_from_log "${tuned_log}" "${mode_dir}")"
|
|
write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${tune_video_delay_us}" "${tune_audio_delay_us}" "${tuned_status}" "${tuned_log}" "${tuned_artifact_dir}" "${tuned_result}"
|
|
annotate_mode_result "${tuned_result}" "${id_base}" "${mode_run_index}" "${repeat_index}" "${LESAVKA_SERVER_RC_REPEAT_COUNT}" "${matrix_sequence}" "tuned"
|
|
cp "${tuned_result}" "${mode_result}"
|
|
run_status=${tuned_status}
|
|
else
|
|
echo " ↪ tune skipped: ${tune_reason:-not ready}"
|
|
fi
|
|
fi
|
|
|
|
if [[ "${run_status}" -ne 0 && "${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL}" == "0" ]]; then
|
|
break 2
|
|
fi
|
|
done
|
|
done
|
|
|
|
summarize_matrix
|
|
|
|
echo "==> done"
|
|
echo "artifact_dir: ${MATRIX_REPORT_DIR}"
|
|
echo "mode_matrix_summary_json: ${MATRIX_SUMMARY_JSON}"
|
|
echo "mode_matrix_summary_csv: ${MATRIX_SUMMARY_CSV}"
|
|
echo "mode_matrix_summary_txt: ${MATRIX_SUMMARY_TXT}"
|
|
echo "mode_delay_recommendations_json: ${MATRIX_DELAY_JSON}"
|
|
echo "mode_delay_recommendations_env: ${MATRIX_DELAY_ENV}"
|
|
echo "mode_static_calibration_json: ${MATRIX_STATIC_JSON}"
|
|
echo "mode_static_calibration_csv: ${MATRIX_STATIC_CSV}"
|
|
echo "mode_static_calibration_txt: ${MATRIX_STATIC_TXT}"
|
|
echo "mode_static_calibration_env: ${MATRIX_STATIC_ENV}"
|
|
echo "mode_matrix_run_log: ${MATRIX_RUN_LOG}"
|
|
|
|
if python3 - <<'PY' "${MATRIX_SUMMARY_JSON}"
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
summary = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
raise SystemExit(0 if summary.get("passed") else 1)
|
|
PY
|
|
then
|
|
exit 0
|
|
fi
|
|
|
|
exit 95
|