FINDINGS 71, ROADMAP P6d. 70.3 named exactly what was missing -- packed.s starts PG_AK/PG_AKF at lump 0 and has no audio seek path -- and priced its absence at a mean 416.5 ms of silence over the arcade's 409 within-container seek targets. pg_aseek is that path: the lump index, the stream position, the remainder accumulator and the byte offset into the group, then the second READ(10) at the lump's own LBA and a re-arm part way into the buffer. Measured off a real volume: 132,162 B of spliced stream accounted for byte by byte in MAME's own capture, across a branch at frame 37 -- four frames into lump 3, deliberately NOT on a group boundary -- in both chip configurations. Skip computed 2,604 B, cadence says 2,604. THE PREDICTOR DOES NOT SEEK, AND THE ERROR IS DC. The MSM6258's accumulator is a pure integrator with no leakage term, so a branch that hands the chip bytes chosen for a state it is not in produces an offset that does not decay. Playing through: DC -355 of 511 with AC 0.00 -- the right shape from the wrong ground -- still -108 four seconds later. STOP and re-PLAY: all 62,500 post-seek samples are EXACTLY a decode from the container's own init, and the whole error is the single constant -65. A re-PLAY is 5.5x better and neither is zero, so PG_ARST is a mailbox with a number under it. The host computes -65 out of the container's bytes and the gate asserts the equality rather than printing both. AND THE ONLY FIX THAT REACHES ZERO IS THE ENCODER'S. A player cannot set the chip's accumulator, only reset it. Resetting the encoder's predictor every frame makes all 119 of the container's branch points exact for 0.33 dB (21.99 -> 21.66), because the step table's floor is a constant 16. That is a DLXP3 and it is deliberately not in tools/encoder. TWO SILENT BUGS, BOTH CAUGHT BY THE CAPTURE. pg_udiv32 trashes d4 and pg_aseek held hz there, so the offset came out 1 byte instead of 2,604 -- 166 ms of the wrong part of the scene at exactly the right rate, every counter agreeing. And one already in the tree that had passed this gate three times: pg_ainit waited on a READ-BACK MTC before PLAY, which is the same test as "a byte has left RAM" only if no byte leaves in between. One does, and the chip then plays the scene one byte in, forever. Found by locating the capture's opening samples in the container image: sector 1 + 1. The witness is now the count that was written. ALL GREEN, two new stages included. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
239 lines
12 KiB
Python
239 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""What a BRANCH costs the chip, and what an encoder could do about it.
|
|
|
|
python3 tools/analysis/37_audio_seek.py [container.dlxp] [--raw au.raw]
|
|
python3 tools/analysis/37_audio_seek.py --gate # the check.sh stage
|
|
|
|
FINDINGS 71. Session 39 put an audio seek path in src/player/packed.s and ran
|
|
it: 132,162 B across a real branch, every byte accounted for in MAME's own
|
|
capture. That settled the BYTES. This settles what is left, which is a
|
|
property of the codec rather than of the player and which no counter in the
|
|
player can reach.
|
|
|
|
THE MSM6258'S ACCUMULATOR HAS NO LEAKAGE TERM. It is a pure integrator of
|
|
deltas, clamped, and nothing pulls it back toward zero. So a branch that hands
|
|
the chip bytes chosen for a state it is not in does not produce a transient with
|
|
a time constant -- it produces a DC OFFSET THAT NEVER DECAYS. The machine run
|
|
measures both designs at one branch point; this measures the CENSUS, over every
|
|
frame boundary of the container, and prices the only fix that is worth anything,
|
|
which is in the encoder and not in the player.
|
|
|
|
* PLAY THROUGH the branch: the chip keeps whatever accumulator and step index
|
|
the previous scene's audio left it in. Unbounded, and its decay is the
|
|
signal's own clamping rather than the recursion forgetting.
|
|
|
|
* STOP and re-PLAY: the accumulator goes to the container's `init` and the
|
|
step index to 0 -- a state this script knows exactly, so the error is
|
|
EXACTLY `init - acc(target)`, constant, forever.
|
|
|
|
* ...and the third option is the ENCODER'S: encode the stream with the
|
|
predictor RESET at every point a branch can land on. Then a re-PLAYing
|
|
player is not close, it is exact. What that costs is a codec question and
|
|
is measured below.
|
|
|
|
NAME THE LAYER. Everything here is host arithmetic over one container and its
|
|
source PCM. The chip's four axes are the ones FINDINGS 66 measured on the
|
|
machine and 67.3 put in the header; the branch behaviour is the one session 39
|
|
ran. No emulator is involved and no rate is claimed.
|
|
"""
|
|
import argparse, math, os, statistics, sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.join(HERE, "..", "encoder"))
|
|
import adpcm
|
|
from dlxp import DLXP
|
|
|
|
|
|
def acc_trajectory(nibbles, dec):
|
|
"""The decoder's accumulator after every sample. This IS the encoder's
|
|
assumed state, because adpcm.encode runs its decoder inside its own search
|
|
loop -- the encoder cannot hold a state the decoder will not reach."""
|
|
lo, hi = adpcm.clamp_bounds(dec["bits"])
|
|
sig, idx = dec["init"], 0
|
|
out = []
|
|
for n in nibbles:
|
|
sig += adpcm.delta(n, adpcm.STEP[idx], dec["variant"])
|
|
sig = lo if sig < lo else (hi if sig > hi else sig)
|
|
idx += adpcm.INDEX_ADJUST[n & 7]
|
|
idx = 0 if idx < 0 else (48 if idx > 48 else idx)
|
|
out.append((sig, idx))
|
|
return out
|
|
|
|
|
|
def encode_reset(src12, dec, period):
|
|
"""adpcm.encode with the predictor forced back to (init, 0) every `period`
|
|
samples. period=None is the ordinary encode.
|
|
|
|
THIS CHANGES THE BYTES, so it is a container property and not a flag a
|
|
player can set. It is written here rather than in tools/encoder/adpcm.py
|
|
because nothing has decided to ship it: 71.5 is the trade and the deciding
|
|
number is a hardware one."""
|
|
if period is None:
|
|
return adpcm.encode(src12, variant=dec["variant"], init=dec["init"],
|
|
bits=dec["bits"])
|
|
out = bytearray()
|
|
for i in range(0, len(src12), period):
|
|
out += adpcm.encode(src12[i:i + period], variant=dec["variant"],
|
|
init=dec["init"], bits=dec["bits"])
|
|
return bytes(out)
|
|
|
|
|
|
def snr(ref, got):
|
|
n = min(len(ref), len(got))
|
|
sig = sum(x * x for x in ref[:n])
|
|
err = sum((ref[i] - got[i]) ** 2 for i in range(n))
|
|
if err == 0:
|
|
return float("inf")
|
|
return 10 * math.log10(sig / err) if sig else float("-inf")
|
|
|
|
|
|
def decode_reset(nib, dec, period):
|
|
if period is None:
|
|
return list(adpcm.decode_state(nib, variant=dec["variant"],
|
|
init=dec["init"], bits=dec["bits"])[0])
|
|
out = []
|
|
for i in range(0, len(nib), period):
|
|
out += list(adpcm.decode_state(nib[i:i + period], variant=dec["variant"],
|
|
init=dec["init"], bits=dec["bits"])[0])
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("container", nargs="?", default="tmp/packed_singe.dlxp")
|
|
ap.add_argument("--raw", default="tmp/au_singe.raw")
|
|
ap.add_argument("--gate", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
d = DLXP(a.container)
|
|
if not d.has_audio:
|
|
sys.exit("this container is silent -- there is no branch to price")
|
|
dec = d.decoder()
|
|
lo, hi = adpcm.clamp_bounds(dec["bits"])
|
|
data = d.audio()
|
|
nib = adpcm.unpack(data, len(data) * 2, order=dec["order"])
|
|
traj = acc_trajectory(nib, dec)
|
|
|
|
fails = []
|
|
def ck(ok, msg):
|
|
print(("OK " if ok else "FAIL ") + msg)
|
|
if not ok:
|
|
fails.append(msg)
|
|
|
|
# ---- 1. THE CENSUS. What a re-PLAY costs at every frame boundary the
|
|
# container has, which is every point a branch in this design can land on:
|
|
# 56.3's targets are frame indices and this player seeks to a frame.
|
|
den = 2 * d.fps
|
|
# THE INDEX IS A NIBBLE INDEX AND THE POSITION IS A BYTE ONE, which is the
|
|
# one conversion in this file and it is worth the line: getting it wrong
|
|
# reads the trajectory at HALF the target and produces a census that is
|
|
# entirely plausible -- a distribution of the right shape over the wrong
|
|
# instants. The cross-check against the machine's own branch point below is
|
|
# what caught it.
|
|
pos = lambda f: 2 * (f * d.aud_hz // den) - 1
|
|
frames = [f for f in range(1, d.nframes) if pos(f) < len(traj)]
|
|
dcs = [abs(dec["init"] - traj[pos(f)][0]) for f in frames]
|
|
idxs = [traj[pos(f)][1] for f in frames]
|
|
dcs_s = sorted(dcs)
|
|
p = lambda q: dcs_s[min(len(dcs_s) - 1, int(q * len(dcs_s)))]
|
|
print(f"--- 1. A RE-PLAYED BRANCH COSTS `init - acc(target)`, EXACTLY AND "
|
|
f"FOREVER. {len(frames)} frame boundaries of {a.container}:")
|
|
print(f" |DC| against the {hi}-unit clamp: mean {statistics.mean(dcs):.1f} "
|
|
f"({statistics.mean(dcs)*100/hi:.1f}%), median {statistics.median(dcs):.0f}, "
|
|
f"p90 {p(0.90)}, worst {max(dcs)} ({max(dcs)*100/hi:.1f}%)")
|
|
print(f" ...and the step index the encoder assumed at those points runs "
|
|
f"{min(idxs)}..{max(idxs)} of 48, median {statistics.median(idxs):.0f} "
|
|
f"-- a re-PLAY sets it to 0, so a branch into a LOUD passage gets the "
|
|
f"offset AND a step index that has to climb back")
|
|
# The machine run's own branch, so the two layers are checked against each
|
|
# other rather than merely agreeing in prose.
|
|
F37 = 37
|
|
dc37 = dec["init"] - traj[pos(F37)][0]
|
|
print(f" frame {F37}, the branch tools/bench/packed_run.sh runs on the "
|
|
f"machine: DC {dc37} -- and MAME's capture measured the chip at "
|
|
f"exactly that, constant over 62,500 samples (FINDINGS 71.3)")
|
|
ck(abs(dc37) == 65,
|
|
f"the host's arithmetic for the machine's own branch point is {abs(dc37)} "
|
|
f"and the capture said 65 -- one number, two layers")
|
|
|
|
# ---- 2. THE DECAY THAT ISN'T. A re-PLAY's error is constant BY
|
|
# CONSTRUCTION -- same step index, same nibbles, one offset -- and playing
|
|
# through is not, because the step indices differ too. The point of
|
|
# measuring it here is that the constancy is a PROPERTY OF THE PREDICTOR
|
|
# and not of the ten seconds this container happens to hold.
|
|
print(f"--- 2. AND IT DOES NOT DECAY. The accumulator is an integrator with "
|
|
f"no leak: a re-PLAY changes the STARTING value and nothing else, so "
|
|
f"the same nibbles produce the same deltas and the offset is carried "
|
|
f"to the end of the stream. The machine agrees -- AC 0.00 over four "
|
|
f"seconds (FINDINGS 71.3). Playing THROUGH the branch is worse and is "
|
|
f"not constant, because the step index differs as well: -355 falling "
|
|
f"to -108 over four seconds, which is clamping and not forgetting.")
|
|
|
|
# ---- 3. THE ENCODER'S FIX, PRICED. Reset the predictor where a branch can
|
|
# land and a re-PLAYing player is EXACT rather than close.
|
|
if not os.path.exists(a.raw):
|
|
print(f" (no {a.raw}: the encoder trade below needs the source PCM)")
|
|
return 1 if fails else 0
|
|
import struct
|
|
pcm = struct.unpack("<%dh" % (os.path.getsize(a.raw) // 2),
|
|
open(a.raw, "rb").read())
|
|
src12 = [max(-2048, min(2047, x >> 4)) for x in pcm][:len(nib)]
|
|
per_frame = d.aud_hz // den * 2 # samples in one frame slot
|
|
print(f"--- 3. THE ONLY FIX THAT MAKES A BRANCH FREE IS THE ENCODER'S, and "
|
|
f"here is its bill. Reset the predictor every N frames when encoding; "
|
|
f"a player that re-PLAYs at a branch landing on one of those points is "
|
|
f"then EXACT, not close:")
|
|
print(f" {"reset every":>24} {'SNR dB':>8} {'vs shipped':>10} "
|
|
f"{'branch points made free':>24}")
|
|
base = None
|
|
rows = []
|
|
for label, period in [("never (shipped)", None),
|
|
(f"{d.cad_f} frames (the cadence)", d.cad_f * per_frame),
|
|
("1 frame", per_frame)]:
|
|
nb = encode_reset(src12, dec, period)
|
|
got = decode_reset(nb, dec, period)
|
|
v = snr(src12, got)
|
|
if base is None:
|
|
base = v
|
|
free = (0 if period is None
|
|
else (len(frames) // d.cad_f if period != per_frame
|
|
else len(frames)))
|
|
rows.append((label, v, v - base, free))
|
|
print(f" {label:>24} {v:8.2f} {v-base:+10.2f} "
|
|
f"{free:>15} of {len(frames)}")
|
|
# THE ASSERTION IS THE ORDER AND THE SIGN, not the decibel: the source PCM
|
|
# is a property of the disc and the encoder is greedy, so the exact figures
|
|
# move with the window. What must not move is that resetting COSTS SNR and
|
|
# that resetting more often costs more -- if it ever came out free, the
|
|
# predictor would not be doing anything and the codec would be pointless.
|
|
ck(rows[1][1] <= rows[0][1] + 1e-9 and rows[2][1] <= rows[1][1] + 1e-9,
|
|
f"resetting the predictor costs SNR, and resetting it more often costs "
|
|
f"more: {rows[0][1]:.2f} -> {rows[1][1]:.2f} -> {rows[2][1]:.2f} dB")
|
|
ck(rows[2][1] > rows[0][1] - 3.0,
|
|
f"...and a reset EVERY FRAME is {rows[0][1]-rows[2][1]:.2f} dB, which is "
|
|
f"the price of making all {len(frames)} of this container's branch points "
|
|
f"exact. The step table's floor is a constant 16 and the recursion "
|
|
f"re-converges in a few samples, which is why twelve resets a second is "
|
|
f"not twelve times anything")
|
|
|
|
print(f"--- 4. WHAT THIS DOES NOT SETTLE.")
|
|
print(f" * Nothing here is a rate and nothing here ran on silicon. The "
|
|
f"branch behaviour is MAME's okim6258 -- PLAY sets the accumulator to "
|
|
f"-2, the step index to 0 and the nibble select to 0 -- which is the "
|
|
f"model FINDINGS 66 fitted to the machine and NOT a measurement of an "
|
|
f"MSM6258V. It joins session 34's fifth hardware item.")
|
|
print(f" * The census is ONE container, ten seconds, one passage at "
|
|
f"-13.4 dBFS (FINDINGS 69). The offset a re-PLAY costs is the signal's "
|
|
f"own value at the cut, so a louder passage costs more, up to the "
|
|
f"clamp -- and the disc peaks at 946 of 2048 (69.2).")
|
|
print(f" * The reset-every-frame encode is NOT in tools/encoder. It is "
|
|
f"a container change (a DLXP3), it costs bytes nothing and SNR "
|
|
f"something, and what decides it is whether a branch is allowed to "
|
|
f"land anywhere or only on frames the encoder was told about.")
|
|
print("AUDIO SEEK GATE " + ("GREEN" if not fails else f"RED: {len(fails)}"))
|
|
return 1 if fails else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|