#!/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())