fix: verify mirrored av stimulus playback
This commit is contained in:
parent
d7aa38b1c1
commit
60d10edd03
14
AGENTS.md
14
AGENTS.md
@ -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
6
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user