ROADMAP P6b, FINDINGS 67. DLXP2: a 64-byte header and then groups -- one audio lump of A sectors, then F records -- so record i is at off_frm + i*rec + (i//F)*A*512 and lump k at off_aud + k*(F*rec + A*512). Still no index and still none needed, which is the packed branch's whole claim surviving the one change that could have ended it. The player carries the third term in six instructions once a frame and zero parsing, and 120 of 120 records are still pixel-exact off a real MB89352 volume with the interleave in, against a silent control that says no picture byte moved. The finding is what 65.3 called padding. A lump is 7,168 B of SPACE; eleven frames of audio is 7,161.4583... B, so the payload alternates 7,161 and 7,162 and the rest is zero. A player that fed the chip the whole lump -- which is what "14 sectors every 11 frames" invites -- runs 0.09% fast, and that is not waste, it is drift: 0.84 ms a group, 1.25 s of lip-sync over the game's 22.8 minutes. What a player carries is one accumulator, acc += 11*15625; n = acc//24; acc %= 24, which is clock.i's shape for clock.i's reason and the third time this tree has met the pattern. The four ADPCM axes ride in the header as fields rather than a version number, and the gate flips each one to prove they earn it: nibble order -31.99 dB, delta formula -24.86, clamp 0.00, accumulator -0.49. Nothing parses a packed container, so the gate partitions the whole file -- 131 spans, no overlap, no gap -- and asserts what a cadence-blind player would read: exactly records 11..119 wrong, and frames 0..10 identical either way, which is how an off-by-one like that survives a rig that checks frame 0. Wire 582.0 + 7.64 = 589.6 KB/s, 65.3's prediction to the tenth. Green light ALL GREEN before (tmp/check_s35_start.log) and after (tmp/check_s35_end.log), with the new stage in it. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
228 lines
12 KiB
Python
228 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""WHAT DOES AUDIO DO TO THE CONTAINER? ROADMAP P6, the half that is not the bus.
|
|
|
|
python3 tools/analysis/32_audio_wire.py [packed.dlxp] [--audio tmp/au_singe.raw]
|
|
[--rate KB/s ...]
|
|
|
|
Session 20 (FINDINGS 52) closed the bus half of P6: a second DMA consumer at
|
|
7,812.5 B/s is 1.25%..1.48% of a frame, about 4% of what the decoder leaves, and
|
|
the 7.8 kB/s figure survived with a unit correction. ROADMAP P6 then says, in
|
|
as many words, that EVERYTHING ELSE in the item is open: extraction, an encoder,
|
|
the container interleave, and what a second stream does to `wire` and therefore
|
|
to `pipe - wire` and therefore to 51.3's refill climb.
|
|
|
|
This file is the container interleave and the wire. It is arithmetic over the
|
|
real container's real geometry -- no MAME run, no board.
|
|
|
|
THE THING THAT MAKES IT INTERESTING, and it is a property of DLXP1 rather than
|
|
of audio: **a packed container has no index and cannot have one.** A record's
|
|
address is `LBA0 + i*97` because a literal frame's length is geometry (FINDINGS
|
|
63, 64.1). Audio is a stream at a rate that has nothing to do with the frame
|
|
rate, so the naive interleave -- give record i the audio bytes belonging to slot
|
|
i -- makes records VARIABLE LENGTH, and the moment records are variable length
|
|
the format needs an index and stops being the format.
|
|
|
|
So the interleave has to be a FIXED CADENCE: every F frames, A whole sectors of
|
|
audio, placed between records. Then
|
|
|
|
LBA(i) = LBA0 + i*RECSEC + floor(i/F)*A
|
|
|
|
which is still two multiplies and a divide -- arithmetic, no index, nothing
|
|
walked -- and the only cost is that A*512 must be at least F frames' worth of
|
|
audio, so the padding is whatever A*512 exceeds it by. Choosing (F, A) is a
|
|
rational-approximation problem and the answer is NOT the obvious cadence.
|
|
"""
|
|
import argparse, os, sys
|
|
from fractions import Fraction
|
|
sys.path.insert(0, "tools/encoder")
|
|
sys.path.insert(0, "tools/analysis")
|
|
import buscost as B
|
|
from dlxp import DLXP, SECTOR
|
|
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("container", nargs="?", default="tmp/packed_singe.dlxp")
|
|
ap.add_argument("--audio", default="tmp/au_singe.raw",
|
|
help="raw s16le mono at the chip rate, from extract_audio.py")
|
|
ap.add_argument("--codec", default="tmp/rc_fr_singe_scsi_span.dlx",
|
|
help="the codec container, for the same arithmetic on the other branch")
|
|
ap.add_argument("--rate", type=float, nargs="*",
|
|
default=[453.6, 500.0, 582.0, 600.0, 650.0, 700.0],
|
|
help="explicit sustained delivery rates, KB/s")
|
|
a = ap.parse_args()
|
|
|
|
d = DLXP(a.container)
|
|
SLOT_S = 1.0 / d.fps
|
|
FRAME_CLK = B.CPU_HZ * SLOT_S if hasattr(B, "CPU_HZ") else 10_000_000 * SLOT_S
|
|
RECSEC = d.rec_bytes // SECTOR
|
|
|
|
print(f"""
|
|
=== THE STREAM =========================================================
|
|
The chip is an MSM6258V on an 8 MHz clock and it has three rates and no
|
|
others. Every budget in this tree is written against the first one.""")
|
|
|
|
RATES = {512: 15625.0, 768: 8_000_000/768, 1024: 7812.5}
|
|
print(f"\n {'divisor':>8} {'samples/s':>11} {'bytes/s':>10} {'B per 1/%d s slot' % d.fps:>19} exact?")
|
|
for div, hz in RATES.items():
|
|
bps = hz / 2
|
|
per = bps / d.fps
|
|
print(f" 8MHz/{div:<4} {hz:11,.1f} {bps:10,.1f} {per:19,.4f} "
|
|
f"{'yes' if per == int(per) else 'NO -- a remainder, like the frame clock (54)'}")
|
|
|
|
HZ = 15625.0
|
|
AU_BPS = HZ / 2 # 4 bits a sample, two samples to a byte
|
|
AU_FRAME = AU_BPS / d.fps # 651.0416... B, and the point is the dots
|
|
|
|
print(f"""
|
|
The shipping rate's per-slot figure is {AU_FRAME:,.4f} B and it is NOT an
|
|
integer -- 8 MHz / 512 / 2 / {d.fps} has a 12 in the denominator that 2**k
|
|
cannot clear. That is the same shape as FINDINGS 54's frame clock: what a
|
|
player carries is a remainder, not a count, and a container that rounds it
|
|
either drifts or underruns.""")
|
|
|
|
if os.path.exists(a.audio):
|
|
n16 = os.path.getsize(a.audio) // 2
|
|
secs = n16 / HZ
|
|
print(f"""
|
|
MEASURED, on the window this project gates everything on (00223 @539.4s,
|
|
{secs:.3f} s, tools/encoder/extract_audio.py):
|
|
{n16:,} samples -> {n16//2:,} B of ADPCM = {n16/2/secs:,.1f} B/s
|
|
which is {AU_BPS:,.1f} to the byte, so the rate is the rate.""")
|
|
|
|
print(f"""
|
|
=== THE INTERLEAVE, AND WHY THE OBVIOUS CADENCE IS THE WRONG ONE =======
|
|
A packed record is {d.rec_bytes:,} B = {RECSEC} sectors EXACTLY and its address is
|
|
arithmetic. Audio rides between records at a fixed cadence -- every F frames,
|
|
A whole sectors -- so that LBA(i) stays arithmetic. A must satisfy
|
|
|
|
A * {SECTOR} >= F * {AU_FRAME:,.4f} i.e. A/F >= {Fraction(int(AU_BPS*2), int(2*SECTOR*d.fps))} = {AU_FRAME/SECTOR:.9f}
|
|
|
|
and everything above that ratio is PADDING that the wire pays for and nothing
|
|
plays. Here is the whole small-F space, best A for each F:""")
|
|
|
|
target = Fraction(int(round(AU_BPS * 2)), 2 * SECTOR * d.fps) # sectors per frame, exact
|
|
|
|
rows, floor = [], None
|
|
for F in range(1, 241):
|
|
A = -(-(target.numerator * F) // target.denominator) # ceil(F * target)
|
|
have, need = A * SECTOR, F * AU_FRAME
|
|
waste = (have - need) / need
|
|
add = have / F * d.fps / 1024 # what the cadence puts on the wire, KB/s
|
|
rows.append((waste, F, A, have, need, add))
|
|
|
|
print(f"\n {'F':>4} {'A':>4} {'A*512 B':>10} {'needs':>12} {'padding':>9} {'waste':>7}"
|
|
f" {'wire adds':>10} {'player RAM':>11}")
|
|
seen = None
|
|
for waste, F, A, have, need, add in rows:
|
|
show = F <= 4 or seen is None or waste < seen - 1e-12
|
|
if seen is None or waste < seen: seen = waste
|
|
if show:
|
|
print(f" {F:4d} {A:4d} {have:10,} {need:12,.1f} {have-need:9,.1f} "
|
|
f"{100*waste:6.2f}% {add:9.2f} KB/s {have:9,} B")
|
|
|
|
best = sorted(rows)
|
|
w, F, A, have, need, add = best[0]
|
|
f1 = next(x for x in rows if x[1] == 1)
|
|
print(f""" THE FLOOR OF THAT SWEEP is F={F}, A={A}: {100*w:.3f}% padding, {add:.2f} KB/s of
|
|
wire for {AU_BPS/1024:.2f} KB/s of audio.
|
|
|
|
THE OBVIOUS CADENCE IS THE WORST ONE. F=1 -- one audio lump per record, which
|
|
is what "interleave the audio into the frame" means if nobody does the
|
|
arithmetic -- needs A={f1[2]} and costs {100*f1[0]:.1f}% padding: {AU_FRAME:,.1f} B rounded up to
|
|
{f1[3]:,}, so {f1[3]-AU_FRAME:,.1f} B of every record is nothing at all, and the wire pays
|
|
{f1[5]:.2f} KB/s for {AU_BPS/1024:.2f} KB/s of audio. That is {f1[5]-add:.2f} KB/s thrown away for
|
|
no reason but the cadence.
|
|
|
|
=== WHAT IT DOES TO THE WIRE ===========================================""")
|
|
|
|
vid_kbs = d.video_kbps()
|
|
for label, cad in (("F=1 (one lump a record)", f1), (f"F={F} (the floor)", best[0])):
|
|
tot = vid_kbs + cad[5]
|
|
print(f" {label:26s} video {vid_kbs:7.1f} + audio {cad[5]:5.2f} = {tot:7.1f} KB/s "
|
|
f"({100*(tot/vid_kbs-1):+.2f}%)")
|
|
|
|
print(f"""
|
|
And this is what B1's acceptance test becomes. The packed container's
|
|
sustained requirement was {vid_kbs:.1f} KB/s SILENT (FINDINGS 61.5, 63) and it is
|
|
{vid_kbs + add:.1f} KB/s with sound. A literal frame's bitrate is geometry and cannot
|
|
be talked down; the audio on top of it is {add:.2f} KB/s and can only be talked down
|
|
by choosing a worse chip rate.""")
|
|
|
|
f11 = next(x for x in rows if x[1] == 11)
|
|
print(f"""
|
|
AND THE CADENCE HAS A SECOND PRICE, WHICH IS RAM. A cadence of F frames means
|
|
the player is holding F frames of audio, and holding it TWICE -- the channel
|
|
fills lump n+1 while the chip drains lump n, the same reason K4 needs two
|
|
record buffers (64.2). So the floor of the sweep is not the answer:
|
|
|
|
F={f1[1]:<3} {f1[3]:>7,} B a lump, {2*f1[3]:>7,} B held {100*f1[0]:6.2f}% padding {f1[5]:5.2f} KB/s
|
|
F={f11[1]:<3} {f11[3]:>7,} B a lump, {2*f11[3]:>7,} B held {100*f11[0]:6.2f}% padding {f11[5]:5.2f} KB/s <- the pick
|
|
F={F:<3} {have:>7,} B a lump, {2*have:>7,} B held {100*w:6.2f}% padding {add:5.2f} KB/s
|
|
|
|
F={f11[1]} buys {100*(f1[0]-f11[0]):.1f} points of padding for {2*f11[3]-2*f1[3]:,} B of RAM, and F={F} buys the
|
|
last {100*(f11[0]-w):.2f} of a point for {2*have-2*f11[3]:,} B more. On a machine where K4 already
|
|
wants 99,328 B for two record buffers, the second trade is not one.
|
|
|
|
=== THE ASYMMETRY: THE CODEC CONTAINER PAYS NONE OF THIS ===============""")
|
|
|
|
if os.path.exists(a.codec):
|
|
sys.path.insert(0, "tools/encoder")
|
|
from dlx import DLX
|
|
c = DLX(a.codec)
|
|
lens = c.record_lengths() if callable(getattr(c, "record_lengths", None)) else c.record_lengths
|
|
cwire = sum(lens) / len(lens) * c.fps / 1024
|
|
print(f""" {os.path.basename(a.codec)}: {c.nframes} records, index {'PRESENT' if c.has_index else 'absent'},
|
|
records already VARIABLE ({min(lens):,}..{max(lens):,} B, mean {sum(lens)/len(lens):,.0f}) and
|
|
sector-aligned since DLX5 (60.1). A container that already carries an index
|
|
and already has variable records can put EXACTLY {AU_FRAME:,.1f} B of audio in record i
|
|
and pad only to the sector it was going to pad to anyway -- so its audio
|
|
padding is not 57.3% and not 1.11%, it is ZERO, and its wire goes
|
|
{cwire:.1f} -> {cwire + AU_BPS/1024:.1f} KB/s ({100*(AU_BPS/1024)/cwire:+.2f}%).
|
|
|
|
THAT IS THE FIRST COST THIS PROJECT HAS FOUND FOR THE PACKED BRANCH'S OWN
|
|
SIMPLIFICATION. "A record's length is geometry, so there is no index and none
|
|
can be needed" (63, 64.1) is what makes the packed player a page of arithmetic
|
|
instead of a parser -- and it is exactly the property that makes a second
|
|
stream at an unrelated rate cost padding, a cadence, and a buffer. It is a
|
|
small cost ({f11[5]-AU_BPS/1024:.2f} KB/s at the pick, {2*f11[3]:,} B of RAM) and it is not zero, and
|
|
nothing in FINDINGS 61-64 predicted it.""")
|
|
else:
|
|
print(f" SKIPPED: no codec container at {a.codec}")
|
|
|
|
print(f"""
|
|
=== WHAT IT DOES TO SLACK (51.3) =======================================
|
|
Slack is ACCUMULATED out of pipe - wire, so a second consumer does not cost a
|
|
fixed amount -- it costs the accumulation rate, and what a branch point costs is
|
|
set by that (51.3, 55.4). Silent vs sounded, at explicit rates:
|
|
|
|
{'pipe':>8} {'silent':>14} {'sounded':>14} what a second of play banks""")
|
|
for kbps in a.rate:
|
|
s_sl, a_sl = kbps - vid_kbs, kbps - (vid_kbs + add)
|
|
def fmt(x): return f"{x:+8.1f} KB/s" if x >= 0 else f"{x:+8.1f} KB/s"
|
|
print(f" {kbps:8.1f} {fmt(s_sl):>14} {fmt(a_sl):>14} "
|
|
+ ("both starve" if a_sl < 0 and s_sl < 0
|
|
else "SOUND IS WHAT BREAKS IT" if s_sl >= 0 > a_sl
|
|
else f"{a_sl/s_sl*100:.0f}% of the silent rate" if s_sl > 0 else ""))
|
|
|
|
AUCLK_LO = AU_FRAME * B.ADPCM_CLK_BYTE_BEST
|
|
AUCLK_HI = AU_FRAME * B.ADPCM_CLK_BYTE_WORST
|
|
print(f"""
|
|
=== AND WHAT IT DOES TO THE FRAME (the half session 20 already closed) ==
|
|
{AU_FRAME:,.1f} B a slot at {B.ADPCM_CLK_BYTE_BEST}..{B.ADPCM_CLK_BYTE_WORST} clocks a byte (the IPL ROM's OWN channel-3
|
|
configuration, read out of the ROM by 21_iplrom_dmac.py, not chosen here) is
|
|
{AUCLK_LO:,.0f}..{AUCLK_HI:,.0f} clocks = {100*AUCLK_LO/FRAME_CLK:.2f}%..{100*AUCLK_HI/FRAME_CLK:.2f}% of a {SLOT_S*1000:.2f} ms slot.
|
|
That reproduces FINDINGS 52 exactly, which is the point of printing it.
|
|
|
|
THE INTERACTION 52 COULD NOT HAVE HAD is with 64.2's write window. A
|
|
DMAC-direct packed player holds the GVRAM window open for the whole data
|
|
phase, and an audio channel stealing the bus during that phase makes the phase
|
|
LONGER -- so audio does not merely cost clocks, it costs DARKNESS:
|
|
|
|
extra dark per slot = {100*AUCLK_LO/FRAME_CLK:.2f}%..{100*AUCLK_HI/FRAME_CLK:.2f}% of the slot, on top of
|
|
record/(burst x slot), which is already 1.0 at the wire
|
|
|
|
It is small against a dark fraction that is already 1.0, and it is not small
|
|
against K4's {100*227553/FRAME_CLK:.1f}% paint. For the CPU-painted player the audio steals
|
|
from the paint and not from the picture, which is the third time this session
|
|
the two players have ranked differently on a column that is not clocks.
|
|
""")
|