fix: verify mirrored av stimulus playback

This commit is contained in:
Brad Stein 2026-05-02 22:00:23 -03:00
parent d7aa38b1c1
commit 60d10edd03
9 changed files with 335 additions and 23 deletions

View File

@ -630,3 +630,17 @@ stimulus plus environmental noise.
- [x] Generate a `manual-review/index.html` with embedded segment captures so runs are easy to inspect by eye. - [x] Generate a `manual-review/index.html` with embedded segment captures so runs are easy to inspect by eye.
- [ ] Re-run the mirrored probe and confirm pair counts rise enough for calibration-ready evidence. - [ ] Re-run the mirrored probe and confirm pair counts rise enough for calibration-ready evidence.
- [ ] If pair counts improve but p95 remains high, move next to server sink handoff jitter and late-run queue pressure. - [ ] If pair counts improve but p95 remains high, move next to server sink handoff jitter and late-run queue pressure.
## 0.17.34 Stimulus Verification Checklist
Context: the first 0.17.33 run did not prove the analyzer changes because the
browser-control path timed out before the local stimulus was started, and the
operator did not see colored flashes or hear coded tones. A sync probe must
verify its own source before asking the analyzer to explain the capture.
- [x] Add a short visible/audible local stimulus preview before the real client starts so framing and audio audibility are human-checkable.
- [x] Record stimulus start/preview tokens, audio context state, and active pulse metadata in `stimulus-status.json`.
- [x] Make each mirrored segment fail early if the local page does not observe `/start` or WebAudio is not running.
- [x] Retry the Tethys browser recording `/start` request to survive transient SSH banner timeouts.
- [x] Open the manual review capture directory in Dolphin after summarization so copied Tethys captures are immediately inspectable.
- [ ] Re-run the mirrored probe and confirm the preview is visible/audible before trusting any pairing diagnosis.

6
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import json
import socketserver import socketserver
import threading import threading
import time import time
import urllib.parse
from pathlib import Path from pathlib import Path
DEFAULT_EVENT_WIDTH_CODES = "1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2" DEFAULT_EVENT_WIDTH_CODES = "1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2"
@ -47,6 +48,8 @@ class StimulusState:
self.args = args self.args = args
self.lock = threading.RLock() self.lock = threading.RLock()
self.start_token = 0 self.start_token = 0
self.preview_token = 0
self.preview_seconds = 0
self.status = { self.status = {
"booted_at": time.time(), "booted_at": time.time(),
"ready": False, "ready": False,
@ -54,6 +57,15 @@ class StimulusState:
"completed": False, "completed": False,
"last_error": None, "last_error": None,
"page_message": "booting", "page_message": "booting",
"stimulus_mode": "idle",
"audio_state": "not-created",
"observed_start_token": None,
"completed_start_token": None,
"observed_preview_token": None,
"completed_preview_token": None,
"pulse_active": False,
"pulse_index": 0,
"pulse_width_code": 0,
"last_update": time.time(), "last_update": time.time(),
} }
self.write_status() self.write_status()
@ -75,6 +87,8 @@ class StimulusState:
snap = dict(self.status) snap = dict(self.status)
snap.update({ snap.update({
"start_token": self.start_token, "start_token": self.start_token,
"preview_token": self.preview_token,
"preview_seconds": self.preview_seconds,
"duration_seconds": self.args.duration_seconds, "duration_seconds": self.args.duration_seconds,
"warmup_seconds": self.args.warmup_seconds, "warmup_seconds": self.args.warmup_seconds,
"pulse_period_ms": self.args.pulse_period_ms, "pulse_period_ms": self.args.pulse_period_ms,
@ -93,11 +107,27 @@ class StimulusState:
"completed": False, "completed": False,
"last_error": None, "last_error": None,
"page_message": "start requested", "page_message": "start requested",
"stimulus_mode": "start",
"start_requested_at": time.time(), "start_requested_at": time.time(),
}) })
self.write_status() self.write_status()
return self.snapshot() return self.snapshot()
def request_preview(self, seconds: int) -> dict:
with self.lock:
self.preview_token += 1
self.preview_seconds = max(1, min(30, seconds))
self.status.update({
"started": False,
"completed": False,
"last_error": None,
"page_message": "preview requested",
"stimulus_mode": "preview",
"preview_requested_at": time.time(),
})
self.write_status()
return self.snapshot()
def page_html() -> str: def page_html() -> str:
return """<!doctype html> return """<!doctype html>
@ -128,6 +158,9 @@ let audioCtx = null;
let oscillator = null; let oscillator = null;
let gain = null; let gain = null;
let startedAt = 0; let startedAt = 0;
let stimulusMode = 'idle';
let lastPulse = { active: false, pulseIndex: 0, widthCode: 0 };
let previewToken = 0;
const pulseColors = { const pulseColors = {
1: '#b81d24', 1: '#b81d24',
2: '#007a3d', 2: '#007a3d',
@ -167,16 +200,51 @@ function eventAt(elapsedMs, command) {
const width = Math.min(command.pulse_period_ms - 1, command.pulse_width_ms * widthCode); const width = Math.min(command.pulse_period_ms - 1, command.pulse_width_ms * widthCode);
return { active: offset < width, pulseIndex, widthCode }; return { active: offset < width, pulseIndex, widthCode };
} }
async function runStimulus(command) { async function runStimulus(command, mode, token) {
if (running) return; if (running) {
await postJson('/status', { last_error: `${mode} token ${token} ignored because stimulus is already running`, page_message: 'stimulus already running' });
return false;
}
running = true; running = true;
stimulusMode = mode;
try {
ensureAudio(); ensureAudio();
await audioCtx.resume(); await audioCtx.resume();
if (audioCtx.state !== 'running') {
throw new Error(`AudioContext did not start; state=${audioCtx.state}`);
}
} catch (err) {
running = false;
stimulusMode = 'idle';
stage.classList.remove('active');
if (gain) gain.gain.setTargetAtTime(0, audioCtx ? audioCtx.currentTime : 0, 0.005);
await postJson('/status', {
ready: true,
started: false,
completed: false,
last_error: String(err && (err.stack || err)),
stimulus_mode: mode,
audio_state: audioCtx ? audioCtx.state : 'not-created',
page_message: `stimulus ${mode} failed`,
});
return false;
}
startedAt = performance.now(); startedAt = performance.now();
await postJson('/status', { ready: true, started: true, completed: false, page_message: 'stimulus running' }); const tokenPayload = mode === 'preview' ? { observed_preview_token: token } : { observed_start_token: token };
await postJson('/status', {
ready: true,
started: true,
completed: false,
last_error: null,
stimulus_mode: mode,
audio_state: audioCtx.state,
page_message: mode === 'preview' ? 'stimulus preview running' : 'stimulus running',
...tokenPayload,
});
const tick = async () => { const tick = async () => {
const elapsed = performance.now() - startedAt; const elapsed = performance.now() - startedAt;
const event = eventAt(elapsed, command); const event = eventAt(elapsed, command);
lastPulse = event;
const active = event.active; const active = event.active;
const pulseIndex = event.pulseIndex; const pulseIndex = event.pulseIndex;
const widthCode = event.widthCode; const widthCode = event.widthCode;
@ -192,25 +260,51 @@ async function runStimulus(command) {
stage.classList.remove('active'); stage.classList.remove('active');
gain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.005); gain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.005);
running = false; running = false;
await postJson('/status', { completed: true, page_message: 'stimulus completed' }); stimulusMode = 'idle';
lastPulse = { active: false, pulseIndex, widthCode };
const completedPayload = mode === 'preview' ? { completed_preview_token: token } : { completed_start_token: token };
await postJson('/status', {
completed: true,
stimulus_mode: mode,
audio_state: audioCtx.state,
page_message: mode === 'preview' ? 'stimulus preview completed' : 'stimulus completed',
...completedPayload,
});
setStatus('completed'); setStatus('completed');
} }
}; };
requestAnimationFrame(tick); requestAnimationFrame(tick);
return true;
} }
async function pollCommand() { async function pollCommand() {
try { try {
const command = await fetch('/command').then(r => r.json()); const command = await fetch('/command').then(r => r.json());
if (command.preview_token !== previewToken) {
const requestedPreviewToken = command.preview_token;
previewToken = requestedPreviewToken;
const previewSeconds = Number(command.preview_seconds || 4);
await runStimulus({ ...command, duration_seconds: previewSeconds, warmup_seconds: 0 }, 'preview', requestedPreviewToken);
return;
}
if (command.start_token !== startToken) { if (command.start_token !== startToken) {
startToken = command.start_token; const requestedStartToken = command.start_token;
await runStimulus(command); const didStart = await runStimulus(command, 'start', requestedStartToken);
if (didStart) startToken = requestedStartToken;
} }
} catch (err) { } catch (err) {
await postJson('/status', { last_error: String(err), page_message: 'command poll failed' }); await postJson('/status', { last_error: String(err), page_message: 'command poll failed' });
} }
} }
setInterval(pollCommand, 200); setInterval(pollCommand, 200);
setInterval(() => postJson('/status', { ready: true, page_message: running ? 'running heartbeat' : 'ready heartbeat' }), 1000); setInterval(() => postJson('/status', {
ready: true,
page_message: running ? `${stimulusMode} heartbeat` : 'ready heartbeat',
stimulus_mode: stimulusMode,
audio_state: audioCtx ? audioCtx.state : 'not-created',
pulse_active: Boolean(lastPulse.active),
pulse_index: Number(lastPulse.pulseIndex || 0),
pulse_width_code: Number(lastPulse.widthCode || 0),
}), 1000);
void postJson('/status', { ready: true, page_message: 'page ready' }); void postJson('/status', { ready: true, page_message: 'page ready' });
setStatus(`ready setStatus(`ready
Point the real webcam at this window. Point the real webcam at this window.
@ -231,29 +325,41 @@ class StimulusHandler(http.server.BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def do_GET(self) -> None: def do_GET(self) -> None:
if self.path in ("/", "/index.html"): parsed = urllib.parse.urlparse(self.path)
if parsed.path in ("/", "/index.html"):
self.state.update({"page_message": "html served"}) self.state.update({"page_message": "html served"})
self._send(200, page_html().encode("utf-8"), "text/html; charset=utf-8") self._send(200, page_html().encode("utf-8"), "text/html; charset=utf-8")
return return
if self.path == "/command": if parsed.path == "/command":
self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) self._send(200, json.dumps(self.state.snapshot()).encode("utf-8"))
return return
if self.path == "/status": if parsed.path == "/status":
self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) self._send(200, json.dumps(self.state.snapshot()).encode("utf-8"))
return return
self._send(404, b"not found", "text/plain; charset=utf-8") self._send(404, b"not found", "text/plain; charset=utf-8")
def do_POST(self) -> None: def do_POST(self) -> None:
parsed = urllib.parse.urlparse(self.path)
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) body = self.rfile.read(length)
if self.path == "/status": if parsed.path == "/status":
payload = json.loads(body.decode("utf-8")) payload = json.loads(body.decode("utf-8"))
self.state.update(payload) self.state.update(payload)
self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) self._send(200, json.dumps(self.state.snapshot()).encode("utf-8"))
return return
if self.path == "/start": if parsed.path == "/start":
self._send(200, json.dumps(self.state.request_start()).encode("utf-8")) self._send(200, json.dumps(self.state.request_start()).encode("utf-8"))
return return
if parsed.path == "/preview":
query = urllib.parse.parse_qs(parsed.query)
seconds = 4
if query.get("seconds"):
try:
seconds = int(query["seconds"][0])
except ValueError:
seconds = 4
self._send(200, json.dumps(self.state.request_preview(seconds)).encode("utf-8"))
return
self._send(404, b"not found", "text/plain; charset=utf-8") self._send(404, b"not found", "text/plain; charset=utf-8")
def log_message(self, fmt: str, *args) -> None: def log_message(self, fmt: str, *args) -> None:

View File

@ -30,6 +30,7 @@ REMOTE_RUNTIME_DIR=${REMOTE_RUNTIME_DIR:-/run/user/1000}
REMOTE_DBUS_ADDRESS=${REMOTE_DBUS_ADDRESS:-} REMOTE_DBUS_ADDRESS=${REMOTE_DBUS_ADDRESS:-}
REMOTE_XAUTHORITY=${REMOTE_XAUTHORITY:-} REMOTE_XAUTHORITY=${REMOTE_XAUTHORITY:-}
READY_TIMEOUT_SECONDS=${READY_TIMEOUT_SECONDS:-120} READY_TIMEOUT_SECONDS=${READY_TIMEOUT_SECONDS:-120}
BROWSER_START_ATTEMPTS=${BROWSER_START_ATTEMPTS:-5}
mkdir -p "${LOCAL_OUTPUT_DIR}" mkdir -p "${LOCAL_OUTPUT_DIR}"
STAMP="$(date +%Y%m%d-%H%M%S)" STAMP="$(date +%Y%m%d-%H%M%S)"
@ -101,6 +102,8 @@ user_pref("permissions.default.camera", 1);
user_pref("permissions.default.microphone", 1); user_pref("permissions.default.microphone", 1);
user_pref("media.autoplay.default", 0); user_pref("media.autoplay.default", 0);
user_pref("media.autoplay.blocking_policy", 0); user_pref("media.autoplay.blocking_policy", 0);
user_pref("media.autoplay.block-webaudio", false);
user_pref("media.autoplay.enabled.user-gestures-needed", false);
user_pref("toolkit.telemetry.reportingpolicy.firstRun", false); user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
user_pref("browser.shell.checkDefaultBrowser", false); user_pref("browser.shell.checkDefaultBrowser", false);
user_pref("browser.tabs.warnOnClose", false); user_pref("browser.tabs.warnOnClose", false);
@ -146,7 +149,16 @@ done
echo "==> triggering browser recording" echo "==> triggering browser recording"
start_json="" start_json=""
if ! start_json="$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start")"; then start_status=1
for attempt in $(seq 1 "${BROWSER_START_ATTEMPTS}"); do
if start_json="$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start")"; then
start_status=0
break
fi
echo "browser consumer start attempt ${attempt}/${BROWSER_START_ATTEMPTS} failed; retrying" >&2
sleep 2
done
if (( start_status != 0 )); then
status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true) status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true)
echo "browser consumer start request failed or timed out" >&2 echo "browser consumer start request failed or timed out" >&2
[[ -n "${status_json:-}" ]] && echo "last status: ${status_json}" >&2 [[ -n "${status_json:-}" ]] && echo "last status: ${status_json}" >&2

View File

@ -47,10 +47,12 @@ LESAVKA_SYNC_CONFIRMATION_SEGMENTS=${LESAVKA_SYNC_CONFIRMATION_SEGMENTS:-1}
LESAVKA_SYNC_REQUIRE_CONFIRMATION_PASS=${LESAVKA_SYNC_REQUIRE_CONFIRMATION_PASS:-${LESAVKA_SYNC_CONFIRM_AFTER_CALIBRATION}} LESAVKA_SYNC_REQUIRE_CONFIRMATION_PASS=${LESAVKA_SYNC_REQUIRE_CONFIRMATION_PASS:-${LESAVKA_SYNC_CONFIRM_AFTER_CALIBRATION}}
STIMULUS_PORT=${STIMULUS_PORT:-18444} STIMULUS_PORT=${STIMULUS_PORT:-18444}
STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10} STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10}
LESAVKA_STIMULUS_PREVIEW_SECONDS=${LESAVKA_STIMULUS_PREVIEW_SECONDS:-4}
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"} LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"}
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"}
LOCAL_BROWSER=${LOCAL_BROWSER:-firefox} LOCAL_BROWSER=${LOCAL_BROWSER:-firefox}
LESAVKA_STIMULUS_BROWSER_KIOSK=${LESAVKA_STIMULUS_BROWSER_KIOSK:-1} LESAVKA_STIMULUS_BROWSER_KIOSK=${LESAVKA_STIMULUS_BROWSER_KIOSK:-1}
LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN=${LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN:-1}
mkdir -p "${LOCAL_OUTPUT_DIR}" mkdir -p "${LOCAL_OUTPUT_DIR}"
STAMP="$(date +%Y%m%d-%H%M%S)" STAMP="$(date +%Y%m%d-%H%M%S)"
@ -192,6 +194,71 @@ PY
done done
} }
wait_for_stimulus_preview_complete() {
local preview_token=$1
local timeout_seconds=$2
local deadline=$(( $(date +%s) + timeout_seconds ))
local status_json=""
while true; do
status_json="$(curl -fsS "http://127.0.0.1:${STIMULUS_PORT}/status" 2>/dev/null || true)"
if [[ -n "${status_json}" ]]; then
local check_status=0
STATUS_JSON="${status_json}" PREVIEW_TOKEN="${preview_token}" python3 - <<'PY' || check_status=$?
import json
import os
import sys
status = json.loads(os.environ["STATUS_JSON"])
token = int(os.environ["PREVIEW_TOKEN"])
if status.get("last_error"):
print(status.get("last_error"), file=sys.stderr)
sys.exit(2)
sys.exit(0 if status.get("completed_preview_token") == token else 1)
PY
if (( check_status == 0 )); then
return 0
fi
if (( check_status == 2 )); then
echo "local stimulus preview failed" >&2
echo "last stimulus status: ${status_json}" >&2
return 1
fi
fi
if (( $(date +%s) >= deadline )); then
echo "local stimulus preview did not complete before timeout" >&2
[[ -n "${status_json:-}" ]] && echo "last stimulus status: ${status_json}" >&2
echo "stimulus server log: ${ARTIFACT_DIR}/stimulus-server.log" >&2
echo "stimulus browser log: ${ARTIFACT_DIR}/stimulus-browser.log" >&2
return 1
fi
sleep 0.2
done
}
run_stimulus_preview() {
if [[ "${LESAVKA_STIMULUS_PREVIEW_SECONDS}" == "0" ]]; then
echo "==> local stimulus preview disabled"
return 0
fi
if ! [[ "${LESAVKA_STIMULUS_PREVIEW_SECONDS}" =~ ^[1-9][0-9]*$ ]]; then
echo "LESAVKA_STIMULUS_PREVIEW_SECONDS must be a non-negative integer" >&2
return 2
fi
echo "==> verifying local stimulus output"
echo " You should see colored flashes and hear test tones for ${LESAVKA_STIMULUS_PREVIEW_SECONDS}s."
local preview_json preview_token
preview_json="$(curl -fsS -X POST "http://127.0.0.1:${STIMULUS_PORT}/preview?seconds=${LESAVKA_STIMULUS_PREVIEW_SECONDS}")"
preview_token="$(PREVIEW_JSON="${preview_json}" python3 - <<'PY'
import json
import os
status = json.loads(os.environ["PREVIEW_JSON"])
print(int(status.get("preview_token") or 0))
PY
)"
wait_for_stimulus_preview_complete "${preview_token}" "$((LESAVKA_STIMULUS_PREVIEW_SECONDS + 10))"
}
start_server_tunnel_if_needed() { start_server_tunnel_if_needed() {
if [[ "${LESAVKA_SERVER_ADDR}" != "auto" ]]; then if [[ "${LESAVKA_SERVER_ADDR}" != "auto" ]]; then
RESOLVED_LESAVKA_SERVER_ADDR="${LESAVKA_SERVER_ADDR}" RESOLVED_LESAVKA_SERVER_ADDR="${LESAVKA_SERVER_ADDR}"
@ -632,6 +699,8 @@ start_local_stimulus() {
cat >"${STIMULUS_PROFILE}/user.js" <<'PREFS' cat >"${STIMULUS_PROFILE}/user.js" <<'PREFS'
user_pref("media.autoplay.default", 0); user_pref("media.autoplay.default", 0);
user_pref("media.autoplay.blocking_policy", 0); user_pref("media.autoplay.blocking_policy", 0);
user_pref("media.autoplay.block-webaudio", false);
user_pref("media.autoplay.enabled.user-gestures-needed", false);
user_pref("toolkit.telemetry.reportingpolicy.firstRun", false); user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
user_pref("browser.shell.checkDefaultBrowser", false); user_pref("browser.shell.checkDefaultBrowser", false);
user_pref("browser.tabs.warnOnClose", false); user_pref("browser.tabs.warnOnClose", false);
@ -648,9 +717,10 @@ PREFS
"${LOCAL_BROWSER}" "${browser_args[@]}" >"${ARTIFACT_DIR}/stimulus-browser.log" 2>&1 & "${LOCAL_BROWSER}" "${browser_args[@]}" >"${ARTIFACT_DIR}/stimulus-browser.log" 2>&1 &
STIMULUS_BROWSER_PID=$! STIMULUS_BROWSER_PID=$!
wait_for_stimulus_page_ready 15 wait_for_stimulus_page_ready 15
run_stimulus_preview
echo "==> position check" echo "==> position check"
echo " Point the real webcam at the stimulus window and keep the selected microphone hearing the tone." echo " Point the real webcam at the stimulus window and keep the selected microphone hearing the tones."
echo " Waiting ${STIMULUS_SETTLE_SECONDS}s before starting the mirrored capture." echo " Waiting ${STIMULUS_SETTLE_SECONDS}s before starting the mirrored capture."
sleep "${STIMULUS_SETTLE_SECONDS}" sleep "${STIMULUS_SETTLE_SECONDS}"
} }
@ -677,13 +747,83 @@ start_real_lesavka_client() {
fi fi
} }
write_stimulus_driver_script() {
local driver_script=$1
local wait_seconds=$2
cat >"${driver_script}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
STIMULUS_PORT=${STIMULUS_PORT}
WAIT_SECONDS=${wait_seconds}
start_json="\$(curl -fsS -X POST "http://127.0.0.1:\${STIMULUS_PORT}/start")"
start_token="\$(START_JSON="\${start_json}" python3 - <<'PY'
import json
import os
status = json.loads(os.environ["START_JSON"])
print(int(status.get("start_token") or 0))
PY
)"
echo " ↪ local_stimulus_start_token=\${start_token}"
deadline=\$(( \$(date +%s) + 10 ))
status_json=""
while true; do
status_json="\$(curl -fsS "http://127.0.0.1:\${STIMULUS_PORT}/status" 2>/dev/null || true)"
if [[ -n "\${status_json}" ]]; then
check_status=0
STATUS_JSON="\${status_json}" START_TOKEN="\${start_token}" python3 - <<'PY' || check_status=\$?
import json
import os
import sys
status = json.loads(os.environ["STATUS_JSON"])
token = int(os.environ["START_TOKEN"])
if status.get("last_error"):
print(status.get("last_error"), file=sys.stderr)
sys.exit(2)
if status.get("observed_start_token") != token:
sys.exit(1)
if not status.get("started"):
sys.exit(1)
if status.get("audio_state") != "running":
sys.exit(1)
sys.exit(0)
PY
if (( check_status == 0 )); then
echo " ↪ local_stimulus_started=true"
break
fi
if (( check_status == 2 )); then
echo "local stimulus failed after /start" >&2
echo "last stimulus status: \${status_json}" >&2
exit 1
fi
fi
if (( \$(date +%s) >= deadline )); then
echo "local stimulus did not observe /start before timeout" >&2
[[ -n "\${status_json:-}" ]] && echo "last stimulus status: \${status_json}" >&2
exit 1
fi
sleep 0.2
done
sleep "\${WAIT_SECONDS}"
EOF
chmod +x "${driver_script}"
}
run_browser_capture_with_real_driver() { run_browser_capture_with_real_driver() {
local segment_label="$1" local segment_label="$1"
local segment_output_dir="$2" local segment_output_dir="$2"
local segment_index="${3:-1}" local segment_index="${3:-1}"
local record_seconds=$((PROBE_DURATION_SECONDS + 3)) local record_seconds=$((PROBE_DURATION_SECONDS + 3))
local wait_seconds=$((PROBE_DURATION_SECONDS + 2)) local wait_seconds=$((PROBE_DURATION_SECONDS + 2))
local driver_command="curl -fsS -X POST http://127.0.0.1:${STIMULUS_PORT}/start >/dev/null; sleep ${wait_seconds}" local driver_script="${segment_output_dir}/trigger-local-stimulus.sh"
write_stimulus_driver_script "${driver_script}" "${wait_seconds}"
local driver_command="${driver_script}"
local reuse_browser_session=0 local reuse_browser_session=0
local analysis_required=1 local analysis_required=1
if [[ "${LESAVKA_SYNC_CONTINUOUS_BROWSER}" == "1" && "${segment_index}" != "1" ]]; then if [[ "${LESAVKA_SYNC_CONTINUOUS_BROWSER}" == "1" && "${segment_index}" != "1" ]]; then
@ -1442,6 +1582,26 @@ sys.exit(1)
PY PY
} }
open_manual_review_in_dolphin() {
local review_dir="${ARTIFACT_DIR}/manual-review"
if [[ "${LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN}" != "1" ]]; then
return 0
fi
if [[ ! -d "${review_dir}" ]]; then
return 0
fi
echo "==> opening manual review captures"
echo " ↪ manual_review_dir=${review_dir}"
if command -v dolphin >/dev/null 2>&1; then
nohup dolphin "${review_dir}" >"${ARTIFACT_DIR}/dolphin.log" 2>&1 &
elif command -v xdg-open >/dev/null 2>&1; then
echo " ↪ dolphin not found; using xdg-open fallback"
nohup xdg-open "${review_dir}" >"${ARTIFACT_DIR}/dolphin.log" 2>&1 &
else
echo " ↪ no graphical file opener found; open ${review_dir} manually"
fi
}
echo "==> prebuilding real client and analyzer" echo "==> prebuilding real client and analyzer"
( (
cd "${REPO_ROOT}" cd "${REPO_ROOT}"
@ -1459,6 +1619,7 @@ run_mirrored_segments || run_status=$?
print_upstream_sync_state "after mirrored run" "${ARTIFACT_DIR}/planner-after.env" print_upstream_sync_state "after mirrored run" "${ARTIFACT_DIR}/planner-after.env"
print_upstream_calibration_state "after mirrored run" "${ARTIFACT_DIR}/calibration-after.env" print_upstream_calibration_state "after mirrored run" "${ARTIFACT_DIR}/calibration-after.env"
summarize_adaptive_probe_metrics summarize_adaptive_probe_metrics
open_manual_review_in_dolphin
if ! check_confirmation_result; then if ! check_confirmation_result; then
run_status=1 run_status=1
fi fi

View File

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

View File

@ -83,6 +83,8 @@ fn browser_sync_script_can_delegate_to_a_real_path_driver() {
"BROWSER_START_TOKEN", "BROWSER_START_TOKEN",
"analysis-failure.json", "analysis-failure.json",
"BROWSER_ANALYSIS_REQUIRED=${BROWSER_ANALYSIS_REQUIRED}", "BROWSER_ANALYSIS_REQUIRED=${BROWSER_ANALYSIS_REQUIRED}",
"BROWSER_START_ATTEMPTS=${BROWSER_START_ATTEMPTS:-5}",
"browser consumer start attempt ${attempt}/${BROWSER_START_ATTEMPTS} failed; retrying",
"--event-width-codes", "--event-width-codes",
"--report-dir \"${LOCAL_REPORT_DIR}\"", "--report-dir \"${LOCAL_REPORT_DIR}\"",
"for attempt in 1 2 3 4 5", "for attempt in 1 2 3 4 5",
@ -134,6 +136,8 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
"LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE=${LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", "LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE=${LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}",
"LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}", "LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}",
"PROBE_AUDIO_GAIN=${PROBE_AUDIO_GAIN:-0.55}", "PROBE_AUDIO_GAIN=${PROBE_AUDIO_GAIN:-0.55}",
"LESAVKA_STIMULUS_PREVIEW_SECONDS=${LESAVKA_STIMULUS_PREVIEW_SECONDS:-4}",
"LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN=${LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN:-1}",
"LESAVKA_SYNC_PROVISIONAL_CALIBRATION=${LESAVKA_SYNC_PROVISIONAL_CALIBRATION:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", "LESAVKA_SYNC_PROVISIONAL_CALIBRATION=${LESAVKA_SYNC_PROVISIONAL_CALIBRATION:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}",
"LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3}", "LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3}",
"LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS:-350}", "LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS:-350}",
@ -158,6 +162,11 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
"BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"", "BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"",
"BROWSER_ANALYSIS_REQUIRED=\"${analysis_required}\"", "BROWSER_ANALYSIS_REQUIRED=\"${analysis_required}\"",
"--audio-gain \"${PROBE_AUDIO_GAIN}\"", "--audio-gain \"${PROBE_AUDIO_GAIN}\"",
"run_stimulus_preview",
"write_stimulus_driver_script",
"local_stimulus_started=true",
"observed_start_token",
"audio_state",
"LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer", "LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer",
"run_mirrored_segments", "run_mirrored_segments",
"summarize_adaptive_probe_metrics", "summarize_adaptive_probe_metrics",
@ -174,6 +183,9 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
"segment-events.jsonl", "segment-events.jsonl",
"manual-review", "manual-review",
"manual_review_html", "manual_review_html",
"manual_review_dir",
"open_manual_review_in_dolphin",
"dolphin",
"capture_path", "capture_path",
"confirmation-summary.json", "confirmation-summary.json",
"confirmation_passed", "confirmation_passed",
@ -228,6 +240,13 @@ fn local_stimulus_matches_sync_analyzer_pulse_contract() {
"--pulse-width-ms", "--pulse-width-ms",
"--marker-tick-period", "--marker-tick-period",
"--audio-gain", "--audio-gain",
"/preview",
"preview_token",
"observed_preview_token",
"completed_preview_token",
"stimulus preview running",
"stimulus preview completed",
"audio_state",
"--event-width-codes", "--event-width-codes",
"event_width_codes", "event_width_codes",
"audio_gain", "audio_gain",