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