ROADMAP P6, everything in the item except the bus half session 20 closed. tools/encoder/adpcm.py is an MSM6258 codec, tools/encoder/extract_audio.py takes the same seconds of the same stream the frames come from, tools/bench/verify_adpcm.py is the gate, tools/analysis/32_audio_wire.py the container arithmetic. There is no reference encoder -- ffmpeg has a decoder for this format and none the other way -- so what is gated is the decoder the encoder runs INSIDE its own nibble search, sample-exact against ffmpeg's over 4,268 nibbles. An encoder that agrees with its own wrong decoder is what that catches. The Singe window: 156,250 samples -> 78,125 B at 21.97 dB, which is 7,812.5 B/s to the byte. Normalising the disc's -13.4 dBFS level moves the SNR 21.97 -> 21.97, so the level is not a lever. And the two published delta formulas are not the same codec. They differ by at most 3 in 12-bit units; encode for one and decode on the other and the SNR goes 21.97 -> -2.88 dB, the noise louder than the signal, because ADPCM is recursive the way the video codec is temporally recursive. Which one the chip runs is now P6a and it is a precondition on shipping any audio. And audio is the first thing the packed branch's simplification has cost anything for. A record has no index BY DESIGN, so audio cannot be per-record without making records variable; it rides a fixed cadence (F, A), the obvious F=1 wastes 57.3% of every audio sector, and the pick is F=11 A=14 -- 0.09% padding, 14,336 B held, wire 582.0 -> 589.6 KB/s. The codec container, which kept its index, pays zero. The MAME experiment did not work and 65.5 says so: :okim6258 is there at $E92001/$E92003, read out of the machine's own program map, and feeding it from Lua recorded silence across control 0..3 x port C 0..15. The register semantics were not guessed at further. FINDINGS 65. check.sh ALL GREEN before and after, with a new stage. 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.rec_bytes * d.fps / 1024
|
|
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.
|
|
""")
|