Session 5 handoff. The user has chosen rate control as the next session's work, so this reads ratectl.py properly before that session starts rather than discovering the problem mid-implementation. FINDINGS 26: encode_rate_controlled() is not sound. H.encode() is temporally recursive -- SKIP blocks copy the previous RECONSTRUCTION -- but rate control builds a ladder of independent whole-sequence encodes and picks each frame from whichever rung fits the budget. Frames then reference reconstructions the decoder never saw. Measured on the Singe window: 67 rung switches, 111 of 120 frames drift, worst frame 43.4% of pixels, reported PSNR overstated by 0.36 dB. It would have wired up cleanly and reported a plausible wrong answer. Two further defects in the same function: the lam ladder runs to 2e5, 250x past the FINDINGS 15 cliff, so a frame that only fits up there is destroyed rather than rate-controlled; and with 5 rungs only two are ever chosen, straddling the operating point by 7.5x. The docstring describes a per-frame binary search, which is the right design -- the implementation is a fixed ladder. The leaky bucket does work and should be kept: 109.1 KB/s against a 110 target. tools/analysis/09_ratectl_drift.py is the regression test and the acceptance criterion: it exits non-zero until zero frames drift. Also corrected the stale 38% blit figure in ratectl.py's profile commentary, which session 5 measured at 53.6% (FINDINGS 24), and recorded the pgrep -f self-kill trap again -- four times across three sessions now. check.sh ALL GREEN. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
129 lines
6.4 KiB
Python
129 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Rate control: hit a target bitrate exactly, so one encoder serves both targets.
|
|
|
|
USER DECISION (session 2): ship TWO quality modes, SASI and SCSI. The codec's
|
|
bitrate ceiling is a build parameter; the encoder is otherwise identical.
|
|
|
|
Mechanism: the hybrid encoder's lagrangian `lam` trades distortion for bytes
|
|
monotonically, so per frame we binary-search lam to land inside a byte budget.
|
|
A leaky bucket lets a quiet frame bank bytes that an action frame can spend --
|
|
without that, quiet frames waste budget and action frames stay ugly.
|
|
|
|
The ceiling is HARD: the 68000 streams at a fixed rate off the disk, and a frame
|
|
that overruns is a dropped frame, not a slow frame.
|
|
|
|
STATUS, session 5: this module is written but STILL NOT WIRED INTO encode.py,
|
|
and FINDINGS 25.3 measured both profiles overshooting their targets by 18% and
|
|
34% on the worst sustained window because of that. Before wiring it up, read
|
|
the correctness note on encode_rate_controlled() -- the lam-ladder approach it
|
|
uses is not sound against a temporally recursive encoder.
|
|
"""
|
|
import numpy as np
|
|
import vq_hybrid as H
|
|
|
|
# Profiles. Bandwidths are the sustained-read figures the player can rely on;
|
|
# see docs/FINDINGS.md 5 -- these are FOLKLORE-grade until the disk benchmark
|
|
# is unblocked, so they are deliberately conservative fractions of the quoted
|
|
# ceiling (audio, seeks and container overhead come out of the same pipe).
|
|
# Calibrated against the corrected rate-distortion curve (FINDINGS 14-15) AND
|
|
# against the bus and CPU budgets (FINDINGS 17). Session 2 initially set these
|
|
# far too low: 45 / 75 KB/s is 12% of the pessimistic SASI folklore figure and
|
|
# 7% of the SCSI one. Nothing justified that -- the numbers were read off the
|
|
# knee of the RD curve, not off the hardware.
|
|
#
|
|
# What actually bounds the high end:
|
|
# - Bus: unmeasured. ~300-500 KB/s SASI / ~1 MB/s SCSI, both FOLKLORE.
|
|
# This is the binding unknown and the reason the disk benchmark matters.
|
|
# - CPU: a full-frame blit is **53.6%** of the 12fps budget -- MEASURED on the
|
|
# emulated 68000, session 5, FINDINGS 24. This line previously said 38%,
|
|
# which was an estimate and was wrong by 41%. And 53.6% is a floor: MAME
|
|
# models no GVRAM wait states, so real hardware is worse.
|
|
# "Raising the bitrate is nearly free on CPU" survives but is now much
|
|
# tighter. It rests on RAW being the cheapest mode to blit, which is still
|
|
# true, but the display path alone now eats over half the frame before any
|
|
# decoding happens. The per-frame path choice of FINDINGS 25.6 (blit vs
|
|
# direct-to-GVRAM, whichever is cheaper for that frame) brings the median
|
|
# back to ~37% and caps the worst case at 53.6%.
|
|
# - Entropy coding is NOT the way to buy headroom here: deflate decode is
|
|
# ~216% of the frame budget on a 68000 and even LZ4 is ~54%. See FINDINGS 17.
|
|
# The rates below are therefore RAW payload, no entropy coding.
|
|
#
|
|
# The two profiles are the SAME codec, decoder and bitstream -- only `lam` differs.
|
|
PROFILES = {
|
|
"sasi": dict(kbps=110, lam=60.0, k1=256, k4=256,
|
|
desc="stock 10MHz ACE/EXPERT, SASI",
|
|
quality="36.9 dB on 00020 / 29.6 dB on 00146 / 27.8 dB on the "
|
|
"Singe window, where it overshoots to 129.6 KB/s",
|
|
util="~105 KB/s = 35% of the pessimistic 300 KB/s SASI figure"),
|
|
"scsi": dict(kbps=280, lam=10.0, k1=256, k4=256,
|
|
desc="Super/XVI, or CZ-6BS1 board in a 10MHz machine",
|
|
quality="39.4 dB on 00020 / 32.3 dB on 00146 / 30.8 dB on the "
|
|
"Singe window, where it overshoots to 373.8 KB/s",
|
|
util="~275 KB/s = 28% of the 1 MB/s SCSI folklore figure"),
|
|
}
|
|
# lam=0 is PIXEL-EXACT against the palettised frame (0.00 dB loss) at ~450 KB/s
|
|
# of raw payload, and costs 53.6% of the CPU budget (not the 38% written here
|
|
# before session 5 -- FINDINGS 24). If the blocked disk
|
|
# benchmark confirms SCSI sustains >=800 KB/s, the `scsi` profile should become
|
|
# lam=0 and the port ships transparent video. That decision is waiting on a
|
|
# measurement, not on a design choice.
|
|
|
|
AUDIO_KBPS = 7.8 # MSM6258 ADPCM 15.6kHz mono -- comes out of the same budget
|
|
|
|
|
|
def frame_budget(kbps, fps=12, audio=AUDIO_KBPS):
|
|
"""bytes per video frame after audio takes its cut"""
|
|
return (kbps - audio) * 1024.0 / fps
|
|
|
|
|
|
def encode_rate_controlled(m, target_kbps, fps=12, bucket_frames=8,
|
|
lam_lo=1.0, lam_hi=2e5, steps=9, verbose=False):
|
|
budget = frame_budget(target_kbps, fps)
|
|
bucket = 0.0 # banked bytes, capped at bucket_frames*budget
|
|
cap = bucket_frames * budget
|
|
out_recon, out_modes, out_sizes, out_lam = [], [], [], []
|
|
|
|
# encode() is whole-sequence; drive it per-lam and pick per frame.
|
|
# Cheaper than re-running the whole encoder per frame: precompute the ladder.
|
|
ladder = []
|
|
lams = np.geomspace(lam_lo, lam_hi, steps)
|
|
for lam in lams:
|
|
e = H.encode(m, lam=float(lam))
|
|
ladder.append(e)
|
|
if verbose:
|
|
print(f" lam={lam:9.0f} mean {e['sizes'].mean():6.0f} B/frame")
|
|
|
|
nf = len(m["idx"])
|
|
for f in range(nf):
|
|
allow = budget + bucket
|
|
# cheapest lam (highest quality) whose size fits the allowance
|
|
pick = len(lams) - 1
|
|
for i in range(len(lams)):
|
|
if ladder[i]["sizes"][f] <= allow:
|
|
pick = i; break
|
|
sz = ladder[pick]["sizes"][f]
|
|
bucket = min(cap, bucket + budget - sz)
|
|
out_recon.append(ladder[pick]["recon"][f])
|
|
out_modes.append(ladder[pick]["modes"][f])
|
|
out_sizes.append(sz); out_lam.append(lams[pick])
|
|
|
|
return dict(recon=out_recon, modes=out_modes, sizes=np.array(out_sizes),
|
|
lam=np.array(out_lam), nb=ladder[0]["nb"], budget=budget)
|
|
|
|
|
|
def summarise(m, enc, target_kbps, fps=12):
|
|
import vq as VQ
|
|
pal = m["pal"]
|
|
rec = [pal[i] for i in enc["recon"]]
|
|
src = [pal[i] for i in m["idx"]]
|
|
p = np.mean([VQ.psnr(o, v) for o, v in zip(m["rgb"], rec)])
|
|
pp = np.mean([VQ.psnr(o, v) for o, v in zip(m["rgb"], src)])
|
|
sz = enc["sizes"]
|
|
mo = np.concatenate(enc["modes"])
|
|
return dict(target=target_kbps, psnr=p, pal=pp, loss=pp - p,
|
|
mean_B=sz.mean(), max_B=sz.max(), budget=enc["budget"],
|
|
kbps=sz.mean() * fps / 1024 + AUDIO_KBPS,
|
|
over=100.0 * np.mean(sz > enc["budget"]),
|
|
skip=100 * (mo == 0).mean(), v1=100 * (mo == 1).mean(),
|
|
v4=100 * (mo == 2).mean())
|