BGM_103's loop bounds are bit offsets in the decoder context, and each wave's duration follows from its declared byte rate, cross-checked against decoded PCM to 0.007 %. Cycle media length 62.34 / 63.29 s against 61.87 s wall: ratio 0.985, where a uniform 8.5 % slowdown predicts 1.085 -- 10 % away and on the other side of 1.0. The method's error bar is its own 1.5 % self-disagreement between two stems that must have equal duration, which is why the loop page refused this conversion for a finer question. It is adequate for an 8.5 % effect. Does NOT settle the frame clock: audio can hold real time on a timer while rendering lags, and 27.6 fps and the 8.5 % splash excess are both frame-clock numbers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
180 lines
6.8 KiB
Python
180 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Does this container's FRAME clock run at the same rate as its AUDIO clock?
|
|
|
|
The corpus reads "the game presents at 27.6 fps" and "the splash dwells run 8.5 %
|
|
long" off wall-clock windows on one container. A guest running ~92 % of real time
|
|
produces identical numbers, and three trials sharing a container cannot separate
|
|
them (ui-keyframe-time-unit.md, corrected 2026-08-30).
|
|
|
|
The AUDIO clock is already bounded: BGM_103's loop cycle is 22.03 M / 22.68 M bits
|
|
of a stream whose declared rate makes it 62.34 / 63.29 media-seconds, against a
|
|
measured 61.87 s wall -- ratio 0.985, where a uniform 8.5 % slowdown predicts
|
|
1.085. ⚠️ But audio on a GPU-less box can hold real time while RENDERING lags, so
|
|
that bounds the audio clock only.
|
|
|
|
This measures a UI ANIMATION's period in the same run as the audio rate. If the
|
|
frame clock ran slow while audio did not, the animation's wall-clock period would
|
|
exceed its declared period while the audio rate stayed nominal.
|
|
|
|
🔴 CONTROL FIRST, and the run is void without it: the same detector must recover
|
|
the TITLE plate's period, which this corpus has measured four times at
|
|
2.12-2.34 s and twice by mid-crossings at 2.530 / 2.540 s. An estimator that
|
|
cannot find a known period cannot be trusted on an unknown one.
|
|
|
|
Phase 2 also answers a question sylpheed-port has open: whether a FOCUSED MENU
|
|
BUTTON glows at all. Eleven focus records declare a 120-unit cycle; only the
|
|
plate is authored to animate.
|
|
|
|
frame_vs_audio_clock.py LOG OUTDIR [title_s] [menu_s]
|
|
"""
|
|
import os, re, subprocess, sys, time
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from ring_row import ring_row
|
|
|
|
LOG, OUT = sys.argv[1], sys.argv[2]
|
|
TITLE_S = float(sys.argv[3]) if len(sys.argv) > 3 else 60.0
|
|
MENU_S = float(sys.argv[4]) if len(sys.argv) > 4 else 90.0
|
|
W, H = 1280, 720
|
|
PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py")
|
|
RO = re.compile(rb"XmaContext(?:Fake)? (\d+): Looped Data: (\d+) < (\d+) \(Start: (\d+)\)")
|
|
T0 = time.time()
|
|
os.makedirs(OUT, exist_ok=True)
|
|
|
|
|
|
def _open():
|
|
return subprocess.Popen(
|
|
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
|
|
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "10",
|
|
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
stdout=subprocess.PIPE, bufsize=W * H * 3 * 4)
|
|
|
|
|
|
def glyph(a):
|
|
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
|
|
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
|
|
|
|
|
|
def deliveries(vk):
|
|
try:
|
|
return len(re.findall(
|
|
(r"RE-INPUT\] XamInputGetKeystrokeEx -> user=\d+ vk=%s flags=0001" % vk).encode(),
|
|
open(LOG, "rb").read()))
|
|
except FileNotFoundError:
|
|
return 0
|
|
|
|
|
|
def press(btn, vk):
|
|
for _ in range(5):
|
|
before = deliveries(vk)
|
|
subprocess.run([sys.executable, PAD, "tap", btn, "0.5"], check=False)
|
|
for _ in range(20):
|
|
time.sleep(0.25)
|
|
if deliveries(vk) > before:
|
|
return True
|
|
return False
|
|
|
|
|
|
def sample(seconds, signal, tag):
|
|
"""Sample `signal(frame)` at ~10 Hz for `seconds`. Returns (t[], v[])."""
|
|
p = _open(); n = W * H * 3
|
|
ts, vs = [], []
|
|
t_end = time.time() + seconds
|
|
seg = time.time()
|
|
while time.time() < t_end:
|
|
if time.time() - seg > 25:
|
|
p.kill(); p = _open(); seg = time.time()
|
|
b = p.stdout.read(n)
|
|
if len(b) < n:
|
|
p.kill(); p = _open(); seg = time.time(); continue
|
|
a = np.frombuffer(b, np.uint8).reshape(H, W, 3).astype(int)
|
|
ts.append(time.time() - T0); vs.append(signal(a))
|
|
p.kill()
|
|
np.savetxt(f"{OUT}/{tag}.tsv", np.column_stack([ts, vs]), fmt="%.4f", delimiter="\t")
|
|
return np.array(ts), np.array(vs, dtype=float)
|
|
|
|
|
|
def period(ts, vs, lo=0.6, hi=8.0):
|
|
"""Dominant period by autocorrelation on a uniform resample."""
|
|
if len(ts) < 40:
|
|
return None, 0.0
|
|
dt = np.median(np.diff(ts))
|
|
if not np.isfinite(dt) or dt <= 0:
|
|
return None, 0.0
|
|
grid = np.arange(ts[0], ts[-1], dt)
|
|
y = np.interp(grid, ts, vs)
|
|
y = y - y.mean()
|
|
if y.std() == 0:
|
|
return None, 0.0
|
|
ac = np.correlate(y, y, "full")[len(y) - 1:]
|
|
ac /= ac[0]
|
|
k0, k1 = max(1, int(lo / dt)), min(len(ac) - 1, int(hi / dt))
|
|
if k1 <= k0:
|
|
return None, 0.0
|
|
k = k0 + int(np.argmax(ac[k0:k1]))
|
|
return k * dt, float(ac[k])
|
|
|
|
|
|
def audio_rate(window):
|
|
"""bits/s of read_offset progress over the last `window` seconds of log."""
|
|
try:
|
|
data = open(LOG, "rb").read()
|
|
except FileNotFoundError:
|
|
return {}
|
|
per = {}
|
|
for m in RO.finditer(data):
|
|
per.setdefault(int(m.group(1)), []).append(int(m.group(2)))
|
|
return {c: (v[0], v[-1], len(v)) for c, v in per.items()}
|
|
|
|
|
|
# ---- phase 1: the TITLE plate, as the control -------------------------------
|
|
print(f"[{time.time()-T0:7.1f}s] waiting for the title", flush=True)
|
|
p = _open(); n = W * H * 3
|
|
while True:
|
|
b = p.stdout.read(n)
|
|
if len(b) < n:
|
|
p.kill(); p = _open(); continue
|
|
a = np.frombuffer(b, np.uint8).reshape(H, W, 3).astype(int)
|
|
if 500 <= glyph(a) <= 2500:
|
|
break
|
|
if time.time() - T0 > 900:
|
|
p.kill(); sys.exit("never reached the title")
|
|
p.kill()
|
|
print(f"[{time.time()-T0:7.1f}s] TITLE — sampling the plate for {TITLE_S:.0f}s", flush=True)
|
|
a0 = audio_rate(0)
|
|
t1, v1 = sample(TITLE_S, glyph, "title-plate")
|
|
per1, ac1 = period(t1, v1)
|
|
print(f" plate period = {per1:.3f} s (autocorr {ac1:.2f}) from {len(t1)} samples"
|
|
if per1 else " plate period: NOT FOUND", flush=True)
|
|
|
|
# ---- phase 2: the MENU ------------------------------------------------------
|
|
print(f"[{time.time()-T0:7.1f}s] pressing A for the menu", flush=True)
|
|
press("A", "5800")
|
|
time.sleep(12)
|
|
p = _open()
|
|
b = p.stdout.read(n); p.kill()
|
|
a = np.frombuffer(b, np.uint8).reshape(H, W, 3).astype(int) if len(b) == n else None
|
|
y = ring_row(Image.fromarray(a.astype(np.uint8))) if a is not None else None
|
|
print(f"[{time.time()-T0:7.1f}s] on the menu, ring y = {y}", flush=True)
|
|
if y is None:
|
|
sys.exit("no ring row on the menu — cannot place the glow window")
|
|
lo, hi = int(y) - 26, int(y) + 26
|
|
ab = audio_rate(0)
|
|
t2, v2 = sample(MENU_S, lambda f: float(f[lo:hi, 480:900].mean()), "menu-focus")
|
|
per2, ac2 = period(t2, v2)
|
|
ae = audio_rate(0)
|
|
|
|
print("\n================ RESULT ================")
|
|
print(f"CONTROL title plate period : {per1:.3f} s (autocorr {ac1:.2f})"
|
|
if per1 else "CONTROL title plate period : NOT FOUND")
|
|
print(f" corpus mid-crossings: 2.530 / 2.540 s")
|
|
print(f"MEASURE menu focus period : {per2:.3f} s (autocorr {ac2:.2f})"
|
|
if per2 else "MEASURE menu focus period : NOT FOUND — no periodic glow detected")
|
|
print("\nAUDIO read_offset progress during the run (bits):")
|
|
for c in sorted(ae):
|
|
s, e, k = ae[c]
|
|
print(f" ctx{c}: {s:,} -> {e:,} ({k} samples)")
|
|
print("FRAME VS AUDIO CLOCK RUN DONE", flush=True)
|