From ab15c0749aa574b59170ba1ea6bc6a8426713655 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:20:55 -0700 Subject: [PATCH] Measure the level off the whole disc, and find the headroom is not worth buying FINDINGS 69, ROADMAP P6 -- the item 66.3 reopened in session 34 and sessions 35 and 36 both deferred. The chip clamps its accumulator at 10 bits INSIDE the recursion, and the ten seconds every audio figure in this tree is quoted on peak at 435 of 511: it fits, and it fits by accident, because that window is a -13.4 dBFS passage. Nothing knew what the loudest passage of the game was. tools/analysis/35_audio_level.py reads every stream of the unique scene footage (00000-00201) through extract_audio.py's own chain -- 1,291.6 s, 201 of 202 streams -- and encodes windows of it with adpcm.CHIP. The disc peaks at 946 of 2048 = -6.71 dBFS (00200 @ 2.11 s), which is 5.35 dB over the clamp, and the census behind that peak is 687 samples of 20,182,000 (0.0034%) in 402 events, 44.0 ms, longest 0.90 ms. THE HEADLINE IS A NEGATIVE: THE LEVEL DOES NOT CHANGE. Forty 2 s windows drawn over the game's timeline at six gains -- the disc's own level (gain 1.0) has the best mean SNR (22.03 dB) and the best median, and loses the worst-window column to -3 dB by 0.04 dB. The gain that guarantees zero clamping disc-wide (0.5402) costs 0.85 dB of mean SNR across the whole game to buy back 1.90 dB on the 2.11 s that clamp, because the OKI step table's floor is a constant 16 and does not scale with the signal. AND 66.3's MECHANISM DOES NOT SURVIVE A CONTROL. Error after a clamp run is elevated ~5x -- and so is the same window at a gain that never clamps, read at the same indices, because those samples are simply loud. Worst ratio 1.28 over 64 offsets, and the clamped encode's whole-window mean |error| is the LOWER of the two (4.71 vs 5.05). adpcm.encode runs the chip's clamp inside its own sixteen-way search, so it never loses the chip's state. The worry was right about the mechanism and aimed one layer too late: an encoder clamping at 12 bits while the chip clamps at 10 is exactly that divergence, and 66 closed it. pack.py gains --audio-gain (default 1.0) so the level is a named parameter with a measurement behind it instead of a shift buried in a list comprehension, and prints the encoded window's peak against the clamp. tmp/packed_singe.dlxp rebuilds byte-identical, all 6,039,040 B. New check.sh stage, ~18 s. Three rig facts in 69.4, because a shipping encoder meets all three: 00176 has no audio track at all; 00199 is 61.31 s of video with 1.25 s of audio; and 18 stream pairs share duration, peak and RMS, 7 of them byte-identical. The 10-bit clamp is a DRIVER SETTING, not a chip constant -- x68k.cpp:1089 sets OUTPUT_10BITS -- so it is MAME's reading of the board, and hardware item 5 is what settles it. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6 --- README.md | 56 +++- docs/FINDINGS.md | 161 ++++++++++ docs/ROADMAP.md | 22 ++ docs/STATUS.md | 113 +++++++ tools/analysis/35_audio_level.py | 496 +++++++++++++++++++++++++++++++ tools/bench/check.sh | 18 ++ tools/encoder/pack.py | 24 +- 7 files changed, 885 insertions(+), 5 deletions(-) create mode 100644 tools/analysis/35_audio_level.py diff --git a/README.md b/README.md index 6f21159..a4578a6 100644 --- a/README.md +++ b/README.md @@ -377,8 +377,9 @@ from what a chip does with a byte written to its data register, with a different answer. The 10-bit clamp costs nothing on this window and only because the window peaks at **435 of 511**: it is a −13.4 dBFS passage with 1.4 dB of headroom, where the encoder had been clamping 12.1 dB higher. So the audio -**level** is an open choice again, downward, and the loudest passage on the disc -is unmeasured (FINDINGS 66). +**level** became an open choice again, downward, with the loudest passage on the +disc unmeasured (FINDINGS 66) — session 37 measured it, and the answer was to +leave the level alone. **Name the layer**: that is MAME's device model, measured end to end through the machine's real transport. It settles the rig — an emulated audio test encoded @@ -426,6 +427,48 @@ gap — and asserts what a cadence-blind player would read: exactly records off-by-one like that survives a rig that checks frame 0. The wire is **582.0 + 7.64 = 589.6 KB/s**, which is 65.3's prediction to the tenth (FINDINGS 67). +**And the container has been heard — held, the bus costs the audio 463 times +the seam.** All **78,125 B** of a DLXP2's audio reached the MSM6258 in order, +sample-exact against the four axes in the container's own header, out of a +player fetching records off the same disc at the same time: two DMA channels at +once, and the interaction is not in the bytes but in the timing. The chip has +**no starvation state** — it goes on decoding nibbles out of whatever byte its +data register still holds — so the interval between a channel counting out and +the CPU arming the next lump is a **held nibble pair driving the predictor**. +Stealing, that seam is **0.51 ms over ten seconds**, because `dma.i`'s hook +services the chip from inside the transfer wait (250,000 of 250,240 looks). +Held, the 68000 is halted and gets 369 looks: **every** lump boundary has a +seam, worst **72.8 ms**, **236 ms total = 2.31% of the audio** — a buzz once a +second, with every byte still correct. 67.2's drift was predicted at 1.25 s over +the game and played at **1.26** (FINDINGS 68). + +**And the audio level is measured now, off the whole disc — the headroom is not +worth buying.** The chip clamps its accumulator at **10 bits inside the +recursion**, and the ten seconds every audio figure here is quoted on peak at +435 of 511, which fits *by accident*: it is a −13.4 dBFS passage. Measured +against the game's own footage instead — all 201 streams that have audio, +21.5 minutes, through the same extract chain — the **disc peaks at 946 of 2048, +−6.71 dBFS, 5.35 dB over the clamp**, and the census behind that peak is **687 +samples of 20.2 million: 402 events, 44.0 ms, longest 0.90 ms**. Forty windows +drawn over the game and encoded at six gains then price the choice, and it is +not close: the disc's own level has the **best mean SNR (22.03 dB)** and the +gain that guarantees zero clamping costs **0.85 dB across the whole game** to +buy back **1.90 dB on the 2.11 seconds that clamp**, because the OKI step +table's floor is a constant and does not scale with the signal. **So the level +does not change** — `--audio-gain` exists so it is a parameter with a +measurement behind it rather than a shift nobody chose. + +And 66.3's *mechanism* — that a clamp is a wrong state the next nibble is +applied to — does not survive a control. Error after a clamp run is elevated +about 5x; so is the same window at a gain that never clamps, read at the same +indices, because those samples are simply loud. Worst ratio **1.28**, and the +clamped encode's whole-window mean error is the **lower** of the two. The reason +is that `adpcm.encode` runs the chip's clamp inside its own sixteen-way search, +so it never loses the chip's state. The worry was right about the mechanism and +aimed one layer too late: an encoder clamping at 12 bits while the chip clamps +at 10 is exactly that divergence, and session 34 had already closed it +(FINDINGS 69). + **The scene graph is in, and the worst gap between two decision points is zero.** `tools/import/scenegraph.py` imports the arcade scene graph — 40 scenes, 516 sequences, 906 input windows — and 5.4% of the game's 612 branch transitions @@ -462,7 +505,8 @@ px68k's own GVRAM model, then **runs the packed player for 120 frames off a real volume and compares every one of them**, then encodes the same window's audio and gates it against ffmpeg's decoder, then asks the emulated MSM6258 which of sixteen decoders it is, then gates the DLXP2 container and every one of its -axes, then prints `ALL GREEN`. +axes, then measures the audio level off every stream of the game's own footage +and gates the disc's peak against the chip's clamp, then prints `ALL GREEN`. ## Reproducing this @@ -611,6 +655,12 @@ tools/analysis/ measurement scripts, numbered in the order they were written. what both cost in accumulated seek slack, across explicit rates. Its cycle counts are PARSED out of the rig's log, not pasted in, so they cannot go stale silently. + 35 reads every stream of the game's own footage and reports + the disc's peak against the chip's 10-bit clamp, the RUN + LENGTHS of the samples above it (687 samples in 402 events is + a different sound from 687 in one), and what six gains cost on + windows drawn over the whole game. --gate asserts those + numbers; --recover is the control that kills 66.3's mechanism. buscost.py is the shared bus-cycle table. The per-block constants live in tools/encoder/vq_hybrid.py and are imported, never copied. diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 874636c..32eeb0f 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -7368,3 +7368,164 @@ not of the format. On silicon the chip latches a byte and consumes both nibbles. * **The silicon is untouched.** All four decoder axes are still MAME's, and session 34's fifth hardware item — play a known nibble stream on a real MSM6258V and record the line out — is still the cheap way to settle them. + +## 69. The audio level is measured off the whole disc — and the headroom is not worth buying (session 37) + +**ROADMAP P6, the item 66.3 reopened and two sessions deferred.** +`tools/analysis/35_audio_level.py` (new), `tools/encoder/pack.py` +(`--audio-gain`), `tools/bench/check.sh`. + +**NAME THE LAYER.** This is **host arithmetic over the source disc**, encoded +with `adpcm.CHIP` — the four axes session 34 measured off MAME's MSM6258. No +68000 ran, no chip was fed. What it settles is what the *encoder* should hand +the chip; what it cannot settle is whether MAME's chip is the chip, which is +still hardware item 5. + +66.3 left this open in as many words: the MSM6258's accumulator clamps at **10 +bits inside the recursion**, the ten seconds every audio figure in this tree is +quoted on peaks at **435 of 511**, and that window is a −13.4 dBFS passage — so +it fits with 1.4 dB to spare and it fits *by accident*. The level could not be +chosen from it. It had to be chosen against the loudest thing the game can play. + +### 69.1 The disc, all of it + +Every stream of the unique scene footage — `00000`-`00201`, FINDINGS 32.1 — +through `extract_audio.py`'s own chain (AC-3 5.1, ffmpeg's default downmix +matrix, mono, 15,625 Hz), then `s16 >> 4`, which is exactly what `pack.py` +hands the encoder. + +| | | +|---|---:| +| streams with an audio track | **201 of 202** | +| audio | **1,291.6 s = 21.5 min** | +| **DISC PEAK** | **946 of 2048 = −6.71 dBFS** (`00200` @ 2.11 s) | +| the chip's clamp, same units | **511 = −12.06 dBFS** | +| so the disc is over the clamp by | **5.35 dB** | +| samples above it | **687 of 20,182,000 = 0.0034%** | +| ...in | **402 events, 44.0 ms**, longest run **14 samples = 0.90 ms**, median run **1** | + +**So the answer to "does the disc clip the chip" is yes, and the number behind +the yes is 44 milliseconds in 21.5 minutes.** A peak alone could not have said +that: 687 samples as one sustained 44 ms burst and 687 samples as 402 +sub-millisecond events are the same census row and completely different sounds, +which is why the tool reports the run lengths and not just the count. + +### 69.2 THE HEADLINE. Every attenuation that buys the headroom costs more than the clamping does + +Forty 2-second windows drawn over the game's own timeline — weighted by stream +length, so a 24 s stream gets twenty times the draws of a 1.2 s one — encoded at +every gain with `adpcm.CHIP`, reported against the *scaled* source, which is the +honest comparison because the listener's amplifier is not this project's problem: + +| gain | dB | mean SNR | median | worst window | windows that clamp | +|---:|---:|---:|---:|---:|---:| +| **1.0000** | **0.00** | **22.03** | **21.55** | 16.83 | 5 of 40 (20 samples) | +| 0.7071 | −3.01 | 21.63 | 21.46 | **16.87** | 0 | +| 0.5402 | −5.35 | 21.18 | 20.74 | 15.26 | 0 | +| 0.5000 | −6.02 | 20.95 | 20.60 | 13.91 | 0 | +| 0.3536 | −9.03 | 20.13 | 19.99 | 11.48 | 0 | +| 0.2500 | −12.04 | 18.81 | 19.12 | 8.23 | 0 | + +**The disc's own level wins every column but one, and it loses that one by 0.04 +dB.** 0.5402 is not a round number and is not meant to be: it is 511/946, the +gain at which the loudest sample on the disc lands exactly on the clamp with +nothing to spare, and buying that guarantee costs **0.85 dB of mean SNR across +the whole game**. + +The mechanism is the OKI step table. Its floor is a constant **16 in 12-bit +units** and does not scale with the signal, so attenuation moves the programme +down toward a quantiser that stays where it is. The three named passages show +both ends of it: + +| passage | gain 1.0 | −3.01 dB | −5.35 dB | −12.04 dB | +|---|---:|---:|---:|---:| +| the disc peak itself (`00200`, 2.11 s, 133 samples clamp) | 17.94 | 19.59 | **19.84** | 19.07 | +| the loudest sustained window (`00037`) | **24.67** | 24.64 | 24.55 | 21.89 | +| a quiet passage (`00001`) | **20.98** | 20.44 | 19.78 | 15.05 | + +**Clamping costs the worst 2 seconds on the disc 1.90 dB. Avoiding it costs the +other 21.5 minutes 0.85.** That is the trade, and it is not close. + +**USER-FACING DECISION, and it is a NEGATIVE: the level does not change.** +`pack.py` still hands the encoder `s16 >> 4`, and the container the gate builds +is byte-identical under the new code — which is checked rather than asserted: +`tmp/packed_singe.dlxp` was rebuilt and `cmp`-ed against the one session 36 ran +off a volume, all 6,039,040 bytes. What is new is +that the shift is now a named parameter, `--audio-gain`, with a measurement +behind its default instead of an accident, and `pack.py` prints the encoded +window's peak against the clamp and the count of samples above it. On the gate +window that line reads **435 of 511, 0 clamped**, which is 66.3 reproduced from +inside the encoder. + +### 69.3 And 66.3's actual worry — that a clamp OUTLIVES its sample — does not survive a control + +66.3's reasoning was exactly right for a recursive codec and is the reason this +was not settled by a peak meter: a clamped accumulator is a **wrong state**, the +next nibble is applied to it, so the damage should persist after the loud sample +has gone. Measure the error after a clamp run and it *is* elevated — about 5x +the off-clamp mean for at least 64 samples. + +That is not evidence. The samples after a clamp run are **loud** samples, where +the step index is high and the error is large whether or not anything clamped. +So the control is the same window at the gain that never clamps, rescaled into +the same units and read at the **same sample indices**: + +| after a clamp run ends | +0 | +1 | +2 | +8 | +32 | +63 | +|---|---:|---:|---:|---:|---:|---:| +| gain 1.0 (133 samples clamp) | 25.05 | 35.05 | 34.34 | 22.19 | 22.27 | 20.36 | +| control at 0.5402, rescaled | 27.02 | 32.13 | 29.89 | 21.37 | 19.41 | 21.33 | +| ratio | 0.93 | 1.09 | 1.15 | 1.04 | **1.15** | 0.95 | + +**Worst ratio over 64 offsets: 1.28. Whole-window mean |error|: 4.71 clamped +against 5.05 for the control** — the clamped encode is the *better* one overall, +which is 69.2 arriving from the other direction. + +The reason is that **the encoder's in-loop decoder models the clamp exactly** +(`adpcm.encode` clamps inside its own sixteen-way search, `adpcm.CHIP["bits"]` += 10). It always knows the state the chip is in, including a saturated one, and +re-chooses the next nibble from there. A clamp costs the samples it happens on +and does not propagate. + +**This is a property of a CHIP-MATCHED encoder and not of the format**, and it +is the sharpest argument yet for session 34's work: an encoder that clamped at +12 bits while the chip clamped at 10 — which is what this tree shipped until +session 34 — would have had the encoder and the chip in *different states*, and +that is the divergence 66.3 described. The worry was correct about the mechanism +and was aimed one layer too late. + +### 69.4 Three rig facts the scan turned up, recorded because a shipping encoder meets all three + +* **`00176` has NO AUDIO TRACK.** 3.0 s of mpeg2video, no stream to decode. + ffmpeg exits non-zero on it, so an encoder that shells out per scene has to + tell "this clip is silent" apart from "the extract failed", and emit silence + of the right length rather than a short lump. +* **`00199` is 61.31 s of video with 1.25 s of audio**, which is 60 s of the + 75 s gap between the disc's 1,366.6 s of footage and its 1,291.6 s of audio. + The remaining ~15 s is spread over 200 streams as sub-half-second differences + — AC-3 frame alignment, not missing content. **A cadence-driven container + computes its lump count from the FRAME count**, so a stream whose audio is + shorter than its video is a case with a right answer (pad) and a wrong one + (run out mid-scene), and nothing has been built that makes that choice yet. +* **18 stream pairs share duration, peak and RMS to three decimals, and 7 of + those pairs are byte-identical audio** (25.0 s, every one of them a 3.5 s + clip — the reused death animations). It is a redundancy, it is small, and no + claim is made here about the *video* of those pairs; Dragon's Lair reuses + footage mirrored, which would leave the audio identical and the picture not. + +### 69.5 What this does NOT settle + +* **SNR is not a listener.** Every number above is a power ratio. 402 clamp + events of a millisecond each are 0.85 dB cheaper than avoiding them *by that + measure*; whether they are audible as a tick on a −6.7 dBFS transient is a + question this project has no instrument for and has never claimed one. +* **The 10-bit clamp is a DRIVER SETTING, not a chip constant.** MAME's + `okim6258` takes the width as a parameter and `x68k.cpp:1089` sets + `OUTPUT_10BITS` for this machine specifically. If a real MSM6258V on a real + X68000 runs 12-bit, the disc clears the clamp by 6.7 dB, the whole of 69.1 is + moot and the answer — do not attenuate — is unchanged and merely cheaper. + **The measurement that settles it is still hardware item 5.** +* **The level was measured, not the loudness.** No perceptual weighting, no + gate, no LUFS: the statistic is peak and RMS of the same samples the encoder + sees, which is the right unit for a clamp question and the wrong one for a + mixing question. +* **Nothing was listened to.** Unchanged since 68.6. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5ba6698..3ab8d60 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -150,6 +150,28 @@ sound as well as a picture. 67.2's drift is measured rather than derived — predicted 1.25 s, played **1.26 s**. **What is left of P6 is the level (66.3) and the refill climb with a second consumer through a real branch point.** +Amended end of session 37: **THE AUDIO LEVEL IS MEASURED, AND THE ANSWER IS +THAT IT DOES NOT CHANGE (FINDINGS 69).** Every stream of the game's own footage +(`00000`-`00201`) through `extract_audio.py`'s chain says the disc peaks at +**946 of 2048 = −6.71 dBFS**, which is **5.35 dB over** the chip's 10-bit clamp +— and the census behind that peak is **687 samples of 20.2 million, 402 events, +44.0 ms in 21.5 minutes**. Forty windows drawn over the game and encoded at six +gains then price the choice: the disc's own level has the best mean SNR +(**22.03 dB**) and the gain that guarantees zero clamping costs **0.85 dB across +the whole game** to buy back **1.90 dB on the 2.11 s that clamp**, because the +OKI step floor is a constant 16 and does not scale with the signal. **And +66.3's mechanism does not survive a control**: error after a clamp run is +elevated ~5x, and so is the same window at a gain that never clamps, read at the +same indices — worst ratio **1.28** — because `adpcm.encode` runs the chip's +clamp inside its own search and therefore never loses the chip's state. The +worry was right about the mechanism and aimed one layer too late; session 34 +had already closed it. `--audio-gain` exists so the level is a parameter with a +measurement behind it, its default is 1.0, and the gate container is +byte-identical. **What is left of P6 is the refill climb with a second consumer +through a real branch point** — and 69.4 adds two small unbuilt cases, a scene +with no audio track (`00176`) and a scene whose audio is shorter than its video +(`00199`). + **THE COMPLETION TARGET IS M3, THE VERTICAL SLICE** (USER DECISION): one scene tree — a decision point, two outcomes, a death clip — with audio, streaming from a real SCSI volume on a stock 2 MB machine, playable. That is the point at which diff --git a/docs/STATUS.md b/docs/STATUS.md index 34bc5af..dd6b2aa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,3 +1,116 @@ +# Status & next-session handoff — end of session 37 (2026-08-25) + +## Session 37: the audio level is measured off the whole disc, and the headroom is not worth buying + +**FINDINGS 69.** ROADMAP P6, the item 66.3 reopened in session 34 and sessions +35 and 36 both deferred. It needed no new machinery on the machine and it did +need the whole disc: `tools/analysis/35_audio_level.py` reads every stream of +the unique scene footage (`00000`-`00201`, FINDINGS 32.1) through +`extract_audio.py`'s own chain and encodes windows of it with `adpcm.CHIP`. + +**What was open.** The chip clamps its accumulator at 10 bits INSIDE the +recursion, so anything the encoder is handed above 511 (12-bit units) is +unreachable. The ten seconds this tree gates everything on peak at 435 of 511 — +it fits, and it fits by accident, because that window is −13.4 dBFS. Nothing +knew what the loudest passage of the game was. + +**What it measures.** + +| | | +|---|---:| +| audio on the game's own footage | 1,291.6 s = 21.5 min, 201 of 202 streams | +| **disc peak** | **946 of 2048 = −6.71 dBFS** (`00200` @ 2.11 s) | +| the clamp | 511 = −12.06 dBFS — the disc is **5.35 dB over** | +| samples above it | 687 of 20,182,000 = **0.0034%** | +| ...in | 402 events, **44.0 ms**, longest 0.90 ms, median run 1 | + +**THE HEADLINE, and it is a negative: the level does not change.** Forty 2 s +windows drawn over the game's timeline, encoded at six gains: the disc's own +level (`s16 >> 4`, gain 1.0) has the best mean SNR (**22.03 dB**) and the best +median, and loses the worst-window column to −3 dB by **0.04 dB**. The gain that +guarantees zero clamping disc-wide (0.5402 = 511/946) costs **0.85 dB of mean +SNR across the whole game** to buy back **1.90 dB on the 2.11 s that clamp**. +The OKI step table's floor is a constant 16 and does not scale with the signal, +so attenuating walks the programme down toward a quantiser that stays put. + +**And 66.3's mechanism does not survive a control.** The worry was that a +clamped accumulator is a wrong state the next nibble is applied to. Error after +a clamp run IS elevated ~5x — and so is the same window at a gain that never +clamps, read at the same indices, because those samples are simply LOUD. Worst +ratio against the control over 64 offsets: **1.28**, and the clamped encode's +whole-window mean |error| is **lower** (4.71 vs 5.05). The reason is that +`adpcm.encode` runs the chip's clamp inside its own sixteen-way search, so it +always knows the state the chip is in, saturated included. **The worry was +correct about the mechanism and aimed one layer too late**: an encoder clamping +at 12 bits while the chip clamps at 10 — what this tree shipped until session +34 — is exactly the divergence described, and 66 already closed it. + +**What changed in the tree.** `--audio-gain` on `pack.py` (default 1.0), which +makes the level a named parameter with a measurement behind it instead of a +shift buried in a list comprehension; `pack.py` now prints the encoded window's +peak against the clamp and the count above it (gate window: **435 of 511, 0 +clamped**). `tmp/packed_singe.dlxp` was rebuilt and is **byte-identical** to the +one session 36 ran off a volume, all 6,039,040 B. A new check.sh stage (~18 s) +gates the disc peak and the clamp census. + +**Three rig facts recorded in 69.4**, because a shipping encoder meets all +three: `00176` has **no audio track at all** (ffmpeg exits non-zero — "silent +clip" has to be distinguishable from "extract failed"); `00199` is 61.31 s of +video with 1.25 s of audio, which is 60 s of the 75 s gap between the disc's +1,366.6 s of footage and its 1,291.6 s of audio, and a cadence-driven container +computes its lump count from the FRAME count; and 18 stream pairs share +duration, peak and RMS, of which 7 are byte-identical audio (25.0 s, all of them +3.5 s clips — the reused death animations). + +## HANDOFF — start here + +**THE TREE IS ALL GREEN**, session 37's stage included. + +### The work, in the order it should be done + +**1. THE REFILL CLIMB WITH A SECOND CONSUMER, through a real branch point** +(51.3, 55.4). Now the oldest open item in P6 and unchanged by this session: the +slack table is in `32_audio_wire.py`, nothing has been run, and session 36 gave +it the player that holds two streams at once. Pointing it at a scene change is +the work; nothing has to be built. + +**2. THE BUFFERING DEPTH, which is cheap.** `PG_ANBUF` is 3 and `PG_APRE` is a +mailbox. Two slots is one constant and one run, and it is 7,168 B of a machine +that has 99,328 B of record buffer to find elsewhere (68.6). + +**3. THE SILENT-CLIP AND SHORT-AUDIO CASES (69.4).** Neither is hard and +neither has an answer in the tree: a scene whose audio is shorter than its +frames, and a scene with no audio track. Both are decided by what the lump +count is computed from, and both are cheap to get wrong quietly, which is this +format's whole failure mode (67.4). + +### What is still BLOCKED, so it is not picked up by mistake + +**K4 — the packed player that is on screen — is conditional on B2**, a board +question. **E7, E4 and C1** are parked (61.8), and **P4a's wiring** is parked +with the ring K3 deleted. + +**The hardware list is unchanged and is the user's**: B1 (sustained AND the +data-phase BURST rate, 64.2 — acceptance is **589.6 KB/s**), B2 (blanking), B3 +(`#EXREQ`), B4 (a byte write to a palette register), and session 34's fifth, +play a known nibble stream on a real MSM6258V and record the line out. **Session +37 sharpens the fifth**: the 10-bit clamp this whole session is about is a +DRIVER SETTING — `x68k.cpp:1089` calls `set_outbits(OUTPUT_10BITS)` — not a chip +constant, so it is MAME's reading of the board. If real silicon runs 12-bit the +disc clears the clamp by 6.7 dB and the answer here is unchanged and cheaper. + +### Reproducing this session + + python3 tools/analysis/35_audio_level.py --gate # ~18 s + python3 tools/analysis/35_audio_level.py --survey 40 --ladder --recover + # ~7 min: the ladder, the disc-wide survey and the clamp-recovery control + ./tools/bench/check.sh # ALL GREEN + +**WHAT IS NEXT.** The refill climb with the second consumer, through a real +branch point. + +--- + # Status & next-session handoff — end of session 36 (2026-08-25) ## Session 36: the container is played, and holding the bus costs the audio diff --git a/tools/analysis/35_audio_level.py b/tools/analysis/35_audio_level.py new file mode 100644 index 0000000..b557b81 --- /dev/null +++ b/tools/analysis/35_audio_level.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""HOW LOUD IS THE DISC? ROADMAP P6, the item FINDINGS 66.3 reopened. + + python3 tools/analysis/35_audio_level.py [--streams 00000-00201] [--json out] + +FINDINGS 66 asked MAME's MSM6258 which decoder it is and got four axes back. +The one with a bill attached is the CLAMP: the chip's accumulator is **10 bits** +and it clamps INSIDE the recursion, so the reachable set of reconstructed +samples is [-512, 511] in the 12-bit units everything in this project counts in +-- a quarter of the 12-bit word `adpcm.py` used to clamp at. + +`pack.py` hands the encoder `s16 >> 4`, i.e. it maps the disc's full scale onto +the 12-bit word, and 66.3 measured the Singe window peaking at **435 of 511**. +That fit with 1.4 dB to spare, and it fit BY ACCIDENT: the window is a -13.4 +dBFS passage. Any passage more than 1.4 dB louder does not merely distort at the +top, it drives the predictor -- a clamped accumulator is a WRONG STATE that the +next nibble is applied to, so the error outlives the loud sample. + +So the level cannot be chosen from the ten seconds this project gates on. It has +to be chosen from the loudest thing the game will ever play, and this file +measures that: every stream of the unique scene footage -- `00000`-`00201`, +1366.6 s, FINDINGS 32.1 -- through the SAME chain `extract_audio.py` uses (AC-3 +5.1, ffmpeg's default downmix matrix, mono, 15,625 Hz), because a level measured +through a different resampler is a level for a different encoder. + +Two statistics, and the difference between them is the whole argument: + + PEAK max |x| over the disc. What must fit under 511 for NOTHING to clamp. + PASSAGE the loudest ~1 s window's peak and RMS. What the ear gets. A single + sample 6 dB above everything else is a click and costs one clamp; a + passage 6 dB above the gate window is where the recursion lives for + fifteen thousand samples. + +It prints the attenuation each choice implies, in dB and as the shift `pack.py` +would have to make, and it does NOT choose. Choosing needs the other half -- +what attenuation costs at the quiet end, where the OKI step table's floor of 16 +(12-bit units) does not scale with the signal -- and that is `--ladder`, which +encodes real passages at real gains with `adpcm.CHIP` and reports the SNR. +""" +import argparse, getpass, json, os, subprocess, sys +import numpy as np + +sys.path.insert(0, "tools/encoder") +import adpcm + +BDROM = os.environ.get("DLX_BDROM") or f"/media/{getpass.getuser()}/BDROM" +STREAM_DIR = f"{BDROM}/BDMV/STREAM" + +HZ = 15625 # the chip's rate, and the only one budgeted for +FPS = 12 +LUMP_FRAMES = 11 # FINDINGS 65.3's cadence: 11 frames of audio +WIN = LUMP_FRAMES * HZ // FPS # 14,322 samples ~ 0.917 s -- one audio lump +HOP = HZ // 4 # 0.25 s blocks; the window is 4 of them (rounded) + +CLAMP_LO, CLAMP_HI = adpcm.clamp_bounds(adpcm.CHIP["bits"]) # -512, 511 +FULL12 = 2048 # what `s16 >> 4` maps full scale to + + +def db(x, ref=FULL12): + return -np.inf if x <= 0 else 20 * np.log10(x / ref) + + +_PCM_CACHE = {} + + +def pcm12(stream, start=None, dur=None): + """One stream as 12-bit signed samples, through extract_audio.py's chain. + + Cached, because the scan, the census and the event walk are three passes + over the same 20 million samples and the whole game is 40 MB of int16. + """ + ck = (stream, start, dur) + if ck in _PCM_CACHE: + return _PCM_CACHE[ck] + cmd = ["ffmpeg", "-v", "error"] + if start is not None: cmd += ["-ss", str(start)] + if dur is not None: cmd += ["-t", str(dur)] + cmd += ["-i", f"{STREAM_DIR}/{stream}.m2ts", "-vn", "-ac", "1", + "-ar", str(HZ), "-f", "s16le", "-acodec", "pcm_s16le", "-"] + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if p.returncode: + # 00176 is 3.0 s of mpeg2video with NO AUDIO TRACK AT ALL. That is a + # fact about the disc, not a failure here, so it is reported rather + # than swallowed -- but anything else is a real error. + if b"does not contain any stream" not in p.stderr: + raise SystemExit(f"ffmpeg failed on {stream}: " + f"{p.stderr.decode(errors='replace')[:400]}") + _PCM_CACHE[ck] = None + return None + x = np.frombuffer(p.stdout, "> 4 is -1. + out = np.clip(x >> 4, -FULL12, FULL12 - 1).astype(np.int16) + _PCM_CACHE[ck] = out + return out + + +def scan(streams): + """Per-stream peak and loudest-passage statistics, in 12-bit units.""" + rows, mute = [], [] + for s in streams: + x = pcm12(s) + if x is None: + mute.append(s) + continue + if x.size == 0: + continue + a = np.abs(x).astype(np.float64) + nb = a.size // HOP + if nb: + bmax = a[:nb * HOP].reshape(nb, HOP).max(1) + bsq = (a[:nb * HOP].reshape(nb, HOP) ** 2).sum(1) + k = max(1, round(WIN / HOP)) + if nb >= k: + # sliding sum over k blocks == the ~1 s lump window + cs = np.concatenate(([0.0], np.cumsum(bsq))) + wrms = np.sqrt((cs[k:] - cs[:-k]) / (k * HOP)) + wpk = np.array([bmax[i:i + k].max() for i in range(nb - k + 1)]) + else: + wrms = np.array([np.sqrt((a ** 2).mean())]) + wpk = np.array([a.max()]) + else: + wrms = np.array([np.sqrt((a ** 2).mean())]) + wpk = np.array([a.max()]) + ipk = int(np.argmax(a)) + irms = int(np.argmax(wrms)) + rows.append(dict(stream=s, n=int(x.size), secs=x.size / HZ, + peak=float(a.max()), peak_t=ipk / HZ, + rms=float(np.sqrt((a ** 2).mean())), + wpeak=float(wpk.max()), + wrms=float(wrms.max()), wrms_t=irms * HOP / HZ)) + return rows, mute + + +def report(rows, mute): + tot = sum(r["secs"] for r in rows) + peak = max(rows, key=lambda r: r["peak"]) + loud = max(rows, key=lambda r: r["wrms"]) + disc_peak = peak["peak"] + + print(f"=== THE DISC, {len(rows)} streams, {tot:,.1f} s = {tot/60:.1f} min " + f"(FINDINGS 32.1 says 1,366.6) ===\n") + if mute: + print(f" {len(mute)} stream(s) carry NO AUDIO TRACK: {', '.join(mute)}" + f" -- a fact about the disc, and a case a shipping encoder has\n" + f" to have an answer for (silence of the right length).\n") + print(f'{"stream":>8}{"secs":>8}{"peak":>7}{"dBFS":>8}{"passage pk":>12}' + f'{"passage rms":>13}{"dBFS":>8} at') + for r in sorted(rows, key=lambda r: -r["wrms"])[:12]: + print(f'{r["stream"]:>8}{r["secs"]:8.1f}{r["peak"]:7.0f}{db(r["peak"]):8.2f}' + f'{r["wpeak"]:12.0f}{r["wrms"]:13.1f}{db(r["wrms"]):8.2f}' + f' {r["wrms_t"]:6.2f} s') + print(" (the twelve loudest PASSAGES; the table is sorted by the window " + "RMS, not the peak)\n") + + print(f" DISC PEAK {disc_peak:.0f} of {FULL12} = {db(disc_peak):.2f} dBFS" + f" ({peak['stream']} @ {peak['peak_t']:.2f} s)") + print(f" LOUDEST PASSAGE peak {loud['wpeak']:.0f}, rms {loud['wrms']:.1f}" + f" = {db(loud['wrms']):.2f} dBFS ({loud['stream']} @ {loud['wrms_t']:.2f} s)") + print(f" THE CLAMP +{CLAMP_HI} / {CLAMP_LO} (it is not symmetric), " + f"{db(CLAMP_HI):.2f} dBFS in the same units\n") + + need = disc_peak / CLAMP_HI + print("=== WHAT THAT COSTS, AS A LEVEL ===\n") + print(f" `s16 >> 4` is what pack.py does today and it puts the disc's own") + print(f" peak at {disc_peak:.0f} against a clamp of {CLAMP_HI}: " + f"{'OVER by' if need > 1 else 'under by'} {abs(20*np.log10(need)):.2f} dB.") + print(f" Fitting the whole disc under the clamp with no sample clamped at") + print(f" all needs a gain of {1/need:.4f} = {-20*np.log10(need):.2f} dB, i.e.") + for sh in (4, 5, 6, 7): + pk = disc_peak / (1 << (sh - 4)) + mark = " <- fits" if pk <= CLAMP_HI else "" + # ~ because a further right shift floors again and this halves; the + # difference is one count and the column is a signpost, not a spec. + print(f" s16 >> {sh} disc peak ~{pk:7.1f} " + f"{'clamps' if pk > CLAMP_HI else 'clear':>6} by " + f"{abs(20*np.log10(pk/CLAMP_HI)):5.2f} dB{mark}") + print() + + # How much of the disc is actually above the clamp at today's level: the + # number that decides whether this is a level question or a limiter question. + return dict(rows=rows, disc_peak=disc_peak, peak_stream=peak["stream"], + peak_t=peak["peak_t"], loud_stream=loud["stream"], + loud_t=loud["wrms_t"], loud_rms=loud["wrms"], + loud_wpeak=loud["wpeak"], clamp=CLAMP_HI, mute=mute) + + +def clip_census(rows, streams, gains): + """At each candidate gain, how many samples of the WHOLE DISC clamp? + + A peak is one number and this is the distribution behind it. A gain that + clamps 12 samples in 22 minutes is a different object from one that clamps + thousands, and the peak alone cannot tell them apart. + """ + print("=== THE CENSUS: how much of the disc is ABOVE the clamp, by gain ===\n") + print(f'{"gain":>8}{"dB":>8}{"samples over":>14}{"of":>12}{"share":>10}' + f'{"worst over":>12}') + tot = 0 + over = {g: 0 for g in gains} + worst = {g: 0.0 for g in gains} + for s in streams: + x = pcm12(s) + if x is None: + continue + a = np.abs(x).astype(np.float64) + tot += a.size + for g in gains: + # ROUNDED, exactly as the ladder and pack.py requantise. Comparing + # the float product instead makes 511/946 report one sample over + # its own clamp, which is arithmetic about floats and not about + # the disc. + v = np.round(a * g) + m = v > CLAMP_HI + over[g] += int(m.sum()) + if m.any(): + worst[g] = max(worst[g], float(v.max() / CLAMP_HI)) + for g in gains: + w = f"{20*np.log10(worst[g]):.2f} dB" if worst[g] else "-" + print(f'{g:8.4f}{20*np.log10(g):8.2f}{over[g]:14,}{tot:12,}' + f'{100*over[g]/tot:9.4f}%{w:>12}') + print() + return dict(total=tot, over={f"{g:.4f}": over[g] for g in gains}) + + +def clamp_events(streams, gain=1.0): + """WHERE the over-clamp samples are, not just how many. + + 687 isolated samples in 22 minutes and one sustained 44 ms burst are the + same census row and completely different sounds, and a clamp inside a + recursion is not a clipped sample -- it is a wrong predictor state that the + next nibble is applied to. So the run lengths are the statistic. + """ + runs = [] + for st in streams: + x = pcm12(st) + if x is None: + continue + m = np.round(np.abs(x).astype(np.float64) * gain) > CLAMP_HI + if not m.any(): + continue + d = np.diff(np.concatenate(([0], m.view(np.int8), [0]))) + beg = np.where(d == 1)[0] + end = np.where(d == -1)[0] + for b, e in zip(beg, end): + runs.append((int(e - b), st, b / HZ)) + runs.sort(reverse=True) + n = sum(r[0] for r in runs) + print(f"=== WHERE THE CLAMPS ARE at gain {gain:.4f} " + f"({len(runs)} events, {n:,} samples = {1000*n/HZ:.1f} ms) ===\n") + print(f'{"run":>6}{"ms":>8} stream at') + for r, st, t in runs[:10]: + print(f'{r:6}{1000*r/HZ:8.2f} {st} {t:7.2f} s') + if runs: + print(f" longest run {runs[0][0]} samples = {1000*runs[0][0]/HZ:.2f} ms; " + f"median run {sorted(r[0] for r in runs)[len(runs)//2]}") + print() + return dict(events=len(runs), samples=n, + longest=runs[0][0] if runs else 0) + + +def ladder(where, gains, dur, label): + """Encode a real passage at each gain with adpcm.CHIP and report the SNR. + + This is the half a peak measurement cannot do. Attenuation buys headroom at + the top and spends resolution at the bottom, because the OKI step table's + floor is a constant 16 in 12-bit units and does not scale with the signal. + The SNR is reported against the SCALED source, which is the honest + comparison: the encoder's job is to reproduce what it was handed, and the + listener's volume knob is not this project's problem. + """ + stream, start = where + x = pcm12(stream, start, dur) + print(f"=== THE LADDER: {label} -- {stream} @ {start:.2f} s, {dur:.2f} s, " + f"{x.size:,} samples ===\n") + print(f'{"gain":>8}{"dB":>8}{"src peak":>10}{"clamped":>9}{"SNR dB":>9}' + f'{"vs 1.0":>8}') + base = None + out = [] + for g in gains: + src = np.clip(np.round(x * g), -FULL12, FULL12 - 1).astype(int).tolist() + nib = adpcm.encode(src, variant=adpcm.CHIP["variant"], + init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"]) + rec = adpcm.decode(nib, variant=adpcm.CHIP["variant"], + init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"]) + s = np.array(src, dtype=np.float64) + r = np.array(rec, dtype=np.float64) + e = ((s - r) ** 2).sum() + snr = 10 * np.log10((s ** 2).sum() / e) if e else np.inf + nclamp = int((np.abs(s) > CLAMP_HI).sum()) + if base is None: + base = snr + print(f'{g:8.4f}{20*np.log10(g):8.2f}{np.abs(s).max():10.0f}{nclamp:9,}' + f'{snr:9.2f}{snr-base:+8.2f}') + out.append(dict(gain=g, snr=snr, clamped=nclamp, + peak=float(np.abs(s).max()))) + print() + return out + + +def survey(rows, gains, n, dur, seed=20260825): + """THE DISC, not three passages of it. + + Three hand-picked passages can be argued with; a sample cannot. `n` windows + are drawn uniformly over the game's own timeline -- weighted by stream + length, so a 24 s stream gets twenty times the draws of a 1.2 s one -- and + every one is encoded at every gain with `adpcm.CHIP`. What is reported is + the distribution: the mean SNR is what the level costs on average, and the + WORST window is what it costs where it matters, because a level is chosen + for the passage it fails on. + """ + rng = np.random.default_rng(seed) + pool = [r for r in rows if r["secs"] >= dur] + w = np.array([r["secs"] for r in pool], dtype=np.float64) + w /= w.sum() + picks = [] + for _ in range(n): + r = pool[int(rng.choice(len(pool), p=w))] + t = float(rng.uniform(0, r["secs"] - dur)) + picks.append((r["stream"], t)) + print(f"=== THE SURVEY: {n} windows of {dur:.1f} s drawn over the whole " + f"{sum(r['secs'] for r in rows)/60:.1f} min, encoded at every gain ===\n") + src = [pcm12(st, t, dur) for st, t in picks] + print(f'{"gain":>8}{"dB":>8}{"mean SNR":>10}{"median":>9}{"WORST":>8}' + f'{"windows w/ clamp":>18}{"samples":>9}') + out = [] + for g in gains: + snrs, nclamp, ncw = [], 0, 0 + for x in src: + v = np.clip(np.round(x * g), -FULL12, FULL12 - 1).astype(int) + k = int((np.abs(v) > CLAMP_HI).sum()) + nclamp += k + ncw += 1 if k else 0 + nib = adpcm.encode(v.tolist(), variant=adpcm.CHIP["variant"], + init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"]) + rec = np.array(adpcm.decode(nib, variant=adpcm.CHIP["variant"], + init=adpcm.CHIP["init"], + bits=adpcm.CHIP["bits"]), dtype=np.float64) + f = v.astype(np.float64) + e = ((f - rec) ** 2).sum() + snrs.append(10 * np.log10((f ** 2).sum() / e) if e else np.inf) + a = np.array(snrs) + print(f'{g:8.4f}{20*np.log10(g):8.2f}{a.mean():10.2f}' + f'{np.median(a):9.2f}{a.min():8.2f}{ncw:14} of {len(src)}{nclamp:9,}', + flush=True) + out.append(dict(gain=g, mean=float(a.mean()), median=float(np.median(a)), + worst=float(a.min()), clamped=nclamp, windows=ncw)) + print() + return out + + +def recover(stream, gain, control, K=64): + """DOES A CLAMP OUTLIVE THE SAMPLE IT HAPPENS ON? 66.3 said it would. + + The worry was exact and it is the right worry for a recursive codec: a + clamped accumulator is a WRONG STATE and the next nibble is applied to it, + so the error should persist after the loud sample has gone. Measuring the + error after a clamp run does show it elevated -- and that is not evidence, + because the samples after a clamp run are LOUD samples, where the step is + large and the error is large anyway. + + So the control is the same window at the gain that never clamps, rescaled + to the same units and read at the SAME sample indices. What the ratio + isolates is the clamp and nothing else. + """ + x = pcm12(stream).astype(np.float64) + + def enc(g): + src = np.clip(np.round(x * g), -FULL12, FULL12 - 1).astype(int) + nib = adpcm.encode(src.tolist(), variant=adpcm.CHIP["variant"], + init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"]) + rec = adpcm.decode(nib, variant=adpcm.CHIP["variant"], + init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"]) + return src.astype(np.float64), np.array(rec, dtype=np.float64) + + s1, r1 = enc(gain) + s2, r2 = enc(control) + e1 = np.abs(s1 - r1) + e2 = np.abs(s2 - r2) / control * gain # the control, in gain's units + m = np.abs(s1) > CLAMP_HI + d = np.diff(np.concatenate(([0], m.view(np.int8), [0]))) + ends = [e for e in np.where(d == -1)[0] if e + K <= e1.size] + p1 = np.array([e1[e:e + K] for e in ends], dtype=np.float64).mean(0) + p2 = np.array([e2[e:e + K] for e in ends], dtype=np.float64).mean(0) + + print(f"=== DOES THE CLAMP OUTLIVE THE SAMPLE? {stream}, gain {gain:g} " + f"against a control at {control:g} ===\n") + print(f" {int(m.sum())} samples clamp in {len(ends)} runs; the profile is " + f"the mean |error| at each\n offset after a run ENDS, in 12-bit units, " + f"against the same offsets of a\n window that never clamps at all.\n") + print(f'{"after":>7}{"clamped":>10}{"control":>10}{"ratio":>8}') + for i in (0, 1, 2, 4, 8, 16, 32, K - 1): + print(f'{"+" + str(i):>7}{p1[i]:10.2f}{p2[i]:10.2f}{p1[i]/p2[i]:8.2f}') + off = ~m + print(f'\n off-clamp mean |err| {e1[off].mean():.2f} vs {e2[off].mean():.2f}') + print(f' whole-window mean |err| {e1.mean():.2f} vs {e2.mean():.2f}') + print(f' worst ratio over the {K} offsets: {(p1/p2).max():.2f}\n') + return dict(stream=stream, gain=gain, control=control, + runs=len(ends), clamped=int(m.sum()), + worst_ratio=float((p1 / p2).max()), + mean_err=float(e1.mean()), mean_err_control=float(e2.mean())) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--first", type=int, default=0) + ap.add_argument("--last", type=int, default=201, + help="the unique scene footage is 00000-00201 (FINDINGS 32.1); " + "00215/00216/00223 are compilations of the same material") + ap.add_argument("--ladder", action="store_true", + help="also encode the loudest and a quiet passage at each gain") + ap.add_argument("--dur", type=float, default=4.0, help="ladder passage seconds") + ap.add_argument("--survey", type=int, default=0, + help="encode N windows drawn over the whole game at each gain") + ap.add_argument("--survey-dur", type=float, default=2.0) + ap.add_argument("--recover", action="store_true", + help="does a clamp outlive its sample? 66.3 said it would") + ap.add_argument("--gate", action="store_true", + help="assert FINDINGS 69's headline numbers, exit 1 if not") + ap.add_argument("--json") + a = ap.parse_args() + + streams = [f"{i:05d}" for i in range(a.first, a.last + 1)] + streams = [s for s in streams if os.path.exists(f"{STREAM_DIR}/{s}.m2ts")] + if not streams: + sys.exit(f"no streams under {STREAM_DIR} -- is the Blu-ray mounted? " + f"(DLX_BDROM)") + + rows, mute = scan(streams) + summary = report(rows, mute) + + # The odd one is not a round number and is not meant to be: it is + # CLAMP/disc peak, the gain at which the disc's own loudest sample lands + # EXACTLY on the clamp, computed from the scan rather than typed in. + exact = round(CLAMP_HI / summary["disc_peak"], 4) + gains = sorted({1.0, 0.7071, exact, 0.5, 0.3536, 0.25}, reverse=True) + summary["exact_gain"] = exact + summary["census"] = clip_census(rows, streams, gains) + summary["events"] = clamp_events(streams, 1.0) + + if a.ladder: + # Three passages, because they answer three different questions. + # PEAK what CLAMPING costs, since this is the only place on the disc + # that clamps at today's level. + # LOUD the loudest sustained window that is long enough to encode. + # QUIET what ATTENUATION costs, which is the other end of the same + # decision and the reason -15 dB is not free. + long = [r for r in rows if r["secs"] >= a.dur] + pk = max(rows, key=lambda r: r["peak"]) # the DISC peak, however short + loud = max(long, key=lambda r: r["wrms"]) + quiet = min(long, key=lambda r: r["wrms"]) + at = lambda r, t: (r["stream"], min(max(0.0, t - a.dur / 2), + max(0.0, r["secs"] - a.dur))) + pkdur = min(a.dur, pk["secs"]) + summary["ladder_peak"] = ladder( + (pk["stream"], min(max(0.0, pk["peak_t"] - pkdur / 2), + max(0.0, pk["secs"] - pkdur))), + gains, pkdur, "THE DISC PEAK ITSELF") + summary["ladder_loud"] = ladder(at(loud, loud["wrms_t"]), gains, a.dur, + "THE LOUDEST SUSTAINED PASSAGE") + summary["ladder_quiet"] = ladder(at(quiet, quiet["wrms_t"]), gains, a.dur, + "A QUIET PASSAGE, for the other end") + + if a.survey: + summary["survey"] = survey(rows, gains, a.survey, a.survey_dur) + + if a.recover: + summary["recover"] = recover(summary["peak_stream"], 1.0, exact) + + if a.gate: + expect = dict(disc_peak=946.0, peak_stream="00200", clamp=511, + events=402, over=687) + bad = [] + for k, v in expect.items(): + got = (summary["events"]["events"] if k == "events" else + summary["events"]["samples"] if k == "over" else summary[k]) + if got != v: + bad.append(f"{k}: expected {v}, measured {got}") + if bad: + print("LEVEL GATE RED -- the disc does not measure as FINDINGS 69 " + "recorded it:") + for b in bad: + print(" " + b) + print(" (a different pressing is a legitimate cause; a different " + "ffmpeg downmix is not)") + sys.exit(1) + print("LEVEL GATE GREEN: disc peak 946 of 2048 at 00200, 5.35 dB over " + "the chip's 511,\n 687 samples in 402 events = 44.0 ms of the " + "game's 21.5 min of audio.") + + if a.json: + json.dump(summary, open(a.json, "w"), indent=1) + print(f"-> {a.json}") + + +if __name__ == "__main__": + main() diff --git a/tools/bench/check.sh b/tools/bench/check.sh index 4da1d98..ff57779 100755 --- a/tools/bench/check.sh +++ b/tools/bench/check.sh @@ -828,4 +828,22 @@ python3 tools/analysis/34_packed_audio.py tmp/packed_singe.dlxp \ grep -aE "^ (OK|FAIL) |^ (order|variant|bits|init) |min of play ->" \ tmp/packed_audio.log +echo "--- session 37: THE AUDIO LEVEL, measured off the whole disc (FINDINGS 69) ---" +# ROADMAP P6, the item 66.3 reopened and two sessions deferred. The chip clamps +# its accumulator at 10 bits INSIDE the recursion, and the ten seconds every +# audio figure in this tree is quoted on peak at 435 of 511 -- which fits, and +# fits BY ACCIDENT, because that window is a -13.4 dBFS passage. +# +# So the level is measured against the loudest thing the game can play, which +# means every stream of the unique scene footage (00000-00201, FINDINGS 32.1) +# through extract_audio.py's own chain. The gate asserts the disc's peak and +# the census behind it; a different pressing is a legitimate reason for it to +# go red, a different ffmpeg downmix is not. +# +# ~18 s, and it needs the Blu-ray mounted like every other stage here. +python3 tools/analysis/35_audio_level.py --gate > tmp/audio_level.log 2>&1 \ + || { cat tmp/audio_level.log; exit 1; } +grep -aE "DISC PEAK|LOUDEST PASSAGE|THE CLAMP|LEVEL GATE|NO AUDIO TRACK|^ 1\.0000|^ 0\.5" \ + tmp/audio_level.log + echo "ALL GREEN" diff --git a/tools/encoder/pack.py b/tools/encoder/pack.py index 7121bb7..b30e7f2 100644 --- a/tools/encoder/pack.py +++ b/tools/encoder/pack.py @@ -35,7 +35,7 @@ NOT use adpcm.py's module defaults, which are ffmpeg's on purpose so that tools/bench/verify_adpcm.py stays a check against an independent implementation. The axes go in the header, so a player never has to be told. """ -import argparse, glob, os, sys +import argparse, glob, math, os, sys import numpy as np from PIL import Image @@ -70,6 +70,13 @@ ap.add_argument("--audio-hz", type=int, default=15625, help="the CHIP's rate, and the rate the .raw was resampled to") ap.add_argument("--cadence", type=int, default=P.CADENCE_F, help="frames between audio lumps (65.3's sweep picks 11)") +ap.add_argument("--audio-gain", type=float, default=1.0, + help="LEVEL, applied before the 12-bit requantisation. 1.0 is " + "s16>>4, the disc's own level, and it is the MEASURED " + "choice: FINDINGS 69 encoded windows drawn over the whole " + "game at six gains and every attenuation that buys " + "headroom under the chip's 10-bit clamp costs more SNR " + "than the clamping it avoids. Below 1.0 is a fallback") a = ap.parse_args() files = sorted(glob.glob(f"{a.frames_dir}/f*.png")) @@ -122,7 +129,16 @@ if a.audio: import struct as _struct raw = open(a.audio, "rb").read() pcm = _struct.unpack("<%dh" % (len(raw) // 2), raw) - src12 = [max(-2048, min(2047, x >> 4)) for x in pcm] + # THE LEVEL. `>> 4` maps the disc's full scale onto the 12-bit word and is + # what every container in this tree has been encoded at; the gain is a + # multiply BEFORE it, so gain 1.0 is byte-identical to what shipped. The + # chip clamps at 10 bits INSIDE the recursion (adpcm.CHIP), so anything the + # gain puts above 511 is unreachable -- and FINDINGS 69 measured that the + # attenuation which avoids that costs more than the clamping does. + g = a.audio_gain + src12 = [max(-2048, min(2047, int(math.floor(x * g)) >> 4)) for x in pcm] + lo12, hi12 = adpcm.clamp_bounds(adpcm.CHIP["bits"]) + nclamp = sum(1 for v in src12 if v > hi12 or v < lo12) need = len(files) * a.audio_hz // (2 * a.fps) nib = adpcm.encode(src12, variant=adpcm.CHIP["variant"], init=adpcm.CHIP["init"], bits=adpcm.CHIP["bits"]) @@ -156,6 +172,10 @@ print(f" record {rec_b:,} B = {rec_b // P.SECTOR} sectors exactly, " if d.has_audio: print(f" DLXP2: audio {d.aud_bytes:,} B at {d.aud_hz:,} Hz, SNR {snr:.2f} dB, " f"cadence F={d.cad_f} A={d.cad_a} ({d.n_lumps} lumps)") + print(f" level gain {a.audio_gain:g}, source peak {max(abs(v) for v in src12)}" + f" of the chip's {hi12}: {nclamp:,} of {len(src12):,} samples " + f"({100*nclamp/len(src12):.4f}%) are above the clamp and cannot be " + f"reached (FINDINGS 69)") print(f" the four axes, in the header: " + ", ".join(f"{k}={v}" for k, v in d.decoder().items())) # STEADY STATE, not the file: the last lump of a 120-frame window feeds 4