1452 lines
58 KiB
Bash
Executable File
1452 lines
58 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:-170000}
|
|
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=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000}
|
|
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_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_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"
|
|
mkdir -p "${MATRIX_REPORT_DIR}"
|
|
|
|
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
|
|
|
|
set +e
|
|
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="${LESAVKA_SERVER_RC_TUNE_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="${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}" \
|
|
PROBE_EVENT_WIDTH_CODES="${PROBE_EVENT_WIDTH_CODES}" \
|
|
PROBE_DURATION_SECONDS="${PROBE_DURATION_SECONDS}" \
|
|
PROBE_WARMUP_SECONDS="${PROBE_WARMUP_SECONDS}" \
|
|
LOCAL_OUTPUT_DIR="${output_dir}" \
|
|
"${SCRIPT_DIR}/run_upstream_av_sync.sh" 2>&1 | tee "${log_path}"
|
|
RUN_MODE_PROBE_STATUS=${PIPESTATUS[0]}
|
|
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
|
|
}
|
|
|
|
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
|
|
[[ "${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}" \
|
|
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}" <<'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}
|
|
|
|
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/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}"
|
|
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,
|
|
) = 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 {}
|
|
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)
|
|
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 = []
|
|
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 signature_paired < signature_expected:
|
|
reasons.append(f"paired coded signatures {signature_paired} < expected {signature_expected}")
|
|
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):
|
|
reasons.append(f"video hiccups {video_hiccups} > {max_video_hiccups_raw}")
|
|
if audio_hiccups > as_int(max_audio_hiccups_raw):
|
|
reasons.append(f"audio hiccups {audio_hiccups} > {max_audio_hiccups_raw}")
|
|
if video_jitter > as_float(max_video_jitter_raw):
|
|
reasons.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):
|
|
reasons.append(f"audio p95 jitter {audio_jitter:.1f}ms > {as_float(max_audio_jitter_raw):.1f}ms")
|
|
if missing > as_int(max_missing_raw):
|
|
reasons.append(f"estimated missing video frames {missing} > {max_missing_raw}")
|
|
if undecodable > as_int(max_undecodable_raw):
|
|
reasons.append(f"undecodable video frames {undecodable} > {max_undecodable_raw}")
|
|
if duplicates > as_int(max_duplicates_raw):
|
|
reasons.append(f"duplicate video frames {duplicates} > {max_duplicates_raw}")
|
|
if low_rms > as_int(max_low_rms_raw):
|
|
reasons.append(f"low-RMS audio windows {low_rms} > {max_low_rms_raw}")
|
|
|
|
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,
|
|
"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
|
|
}
|
|
|
|
summarize_matrix() {
|
|
python3 - <<'PY' "${MATRIX_REPORT_DIR}" "${MATRIX_SUMMARY_JSON}" "${MATRIX_SUMMARY_CSV}" "${MATRIX_SUMMARY_TXT}" "${MATRIX_DELAY_JSON}" "${MATRIX_DELAY_ENV}"
|
|
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])
|
|
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)
|
|
results.append(result)
|
|
except Exception:
|
|
continue
|
|
|
|
delay_recommendations = {}
|
|
video_delay_entries = []
|
|
audio_delay_entries = []
|
|
for result in results:
|
|
mode = result.get("mode")
|
|
if not mode:
|
|
continue
|
|
sync = result.get("sync") or {}
|
|
calibration = result.get("output_delay_calibration") or {}
|
|
required_pairs = calibration.get("min_pairs") or 13
|
|
confirmed = (
|
|
sync.get("passed") is True
|
|
and (sync.get("paired_event_count") or 0) >= required_pairs
|
|
)
|
|
candidate_video = calibration.get("video_target_offset_us")
|
|
candidate_audio = calibration.get("audio_target_offset_us")
|
|
video_delay = result.get("video_delay_us")
|
|
audio_delay = result.get("audio_delay_us")
|
|
status = "confirmed" if confirmed else "tested"
|
|
candidate_available = (
|
|
calibration.get("ready") is True
|
|
and (calibration.get("paired_event_count") or 0) > 0
|
|
and isinstance(candidate_video, int)
|
|
and isinstance(candidate_audio, int)
|
|
)
|
|
if not confirmed and candidate_available:
|
|
video_delay = candidate_video
|
|
audio_delay = candidate_audio
|
|
status = "candidate_unconfirmed"
|
|
elif not confirmed:
|
|
status = "unavailable"
|
|
video_delay = None
|
|
audio_delay = None
|
|
if status != "unavailable" and isinstance(video_delay, int) and isinstance(audio_delay, int):
|
|
video_delay_entries.append(f"{mode}={video_delay}")
|
|
audio_delay_entries.append(f"{mode}={audio_delay}")
|
|
delay_recommendations[mode] = {
|
|
"status": status,
|
|
"audio_delay_us": audio_delay,
|
|
"video_delay_us": video_delay,
|
|
"tested_audio_delay_us": result.get("audio_delay_us"),
|
|
"tested_video_delay_us": result.get("video_delay_us"),
|
|
"sync_status": sync.get("status"),
|
|
"median_skew_ms": sync.get("median_skew_ms"),
|
|
"paired_event_count": sync.get("paired_event_count"),
|
|
"paired_confidence_median": sync.get("paired_confidence_median"),
|
|
"activity_pair_disagreement_ms": sync.get("activity_pair_disagreement_ms"),
|
|
}
|
|
|
|
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),
|
|
"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"
|
|
)
|
|
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"
|
|
)
|
|
|
|
fieldnames = [
|
|
"mode",
|
|
"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",
|
|
]
|
|
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"),
|
|
"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"),
|
|
})
|
|
|
|
lines = [
|
|
f"Server-to-RC mode matrix for {root}",
|
|
f"- modes: {len(results)}",
|
|
f"- verdict: {'pass' if summary['passed'] else 'fail'}",
|
|
]
|
|
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')}: "
|
|
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"
|
|
)
|
|
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', '')}"
|
|
)
|
|
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}")
|
|
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)}")
|
|
summary_txt.write_text("\n".join(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 " ↪ 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 " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS} min_pairs=${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}"
|
|
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}"
|
|
|
|
prime_remote_sudo
|
|
sleep_start_delay
|
|
prebuild_probe_tools
|
|
|
|
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}")
|
|
video_delay_us="$(lookup_video_delay_us "${mode}")"
|
|
audio_delay_us="$(lookup_audio_delay_us "${mode}")"
|
|
id="$(mode_id "${mode}")"
|
|
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"
|
|
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}: video_delay_us=${video_delay_us} audio_delay_us=${audio_delay_us}"
|
|
reconfigure_server_mode "${mode}" "${width}" "${height}" "${fps}"
|
|
wait_tethys_media_ready "${mode}" "${width}" "${height}" "${fps}"
|
|
|
|
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}"
|
|
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}: 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}"
|
|
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
|
|
fi
|
|
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}"
|
|
|
|
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
|