Put sound on the wire, and find three LSBs are worth 25 dB

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
This commit is contained in:
prosolis
2026-08-25 09:11:03 -07:00
parent 6f698ca226
commit f925a1dd9a
12 changed files with 1069 additions and 6 deletions
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""MSM6258 (OKI/Dialogic) 4-bit ADPCM -- encoder, decoder, and the fact that
there are TWO decoders and they are not the same one.
The X68000's ADPCM is an OKI MSM6258V clocked at 8 MHz, dividing to 15,625 /
10,417 / 7,812.5 samples a second, 4 bits each, two samples to a byte
(FINDINGS 52, buscost.ADPCM_SAMPLE_HZ). The sample word is 12 bits signed.
WHY THIS FILE HAS TWO DECODERS. Nothing in this repo can be trusted to say what
the chip does, and the two references available on this machine DISAGREE:
VARIANT 'shift' delta = ((2*(n&7) + 1) * step) >> 3
This is ffmpeg's `adpcm_ima_oki`, and `gate_vs_ffmpeg()`
reproduces it SAMPLE-EXACT, so it is not a reading of source
code -- it is a measurement of the decoder that ships.
VARIANT 'terms' delta = step/8 + (n&4 ? step : 0) + (n&2 ? step/2 : 0)
+ (n&1 ? step/4 : 0), each term truncated
This is the OKI datasheet's own form, the one an ADPCM chip
can actually build out of shifts and adds, and it is what
MAME's okim6258 is understood to compute. NOT VERIFIED HERE:
no MAME source tree is on this machine (FINDINGS 64.4).
They differ on 445 of 2,268 sampled nibbles, by up to 4 in 12-bit units --
small, and small is not zero. Which one the machine runs is an open question
with an experiment attached: MAME's x68000 HAS an okim6258, so it can be asked
rather than argued about.
Nibble order is HIGH NIBBLE FIRST within a byte -- measured, not assumed, by the
same gate: reading low-first mismatches ffmpeg on 1,728 of 2,268 samples.
"""
# The 49-entry OKI step table. floor(16 * 1.1**k) for k in 0..48 -- built rather
# than pasted, so a transcription slip is not one of the things that can be
# wrong here.
STEP = [int(16 * 1.1**k) for k in range(49)]
# The nibble magnitude's effect on the step index. Four quiet nibbles walk it
# down one, four loud ones walk it up by more.
INDEX_ADJUST = (-1, -1, -1, -1, 2, 4, 6, 8)
SAMPLE_MIN, SAMPLE_MAX = -2048, 2047 # the 12-bit DAC word
VARIANTS = ("shift", "terms")
def delta(nibble, step, variant):
"""The reconstruction step for one nibble, in 12-bit units."""
if variant == "shift":
d = ((2 * (nibble & 7) + 1) * step) >> 3
elif variant == "terms":
d = step // 8
if nibble & 4: d += step
if nibble & 2: d += step // 2
if nibble & 1: d += step // 4
else:
raise ValueError(f"unknown variant {variant!r}")
return -d if nibble & 8 else d
def decode(nibbles, variant="shift"):
"""Nibbles -> 12-bit signed samples. State is (signal, step index), both
zero at the start of a stream, which is what the chip resets to."""
signal, idx, out = 0, 0, []
for n in nibbles:
signal += delta(n, STEP[idx], variant)
signal = SAMPLE_MIN if signal < SAMPLE_MIN else (
SAMPLE_MAX if signal > SAMPLE_MAX else signal)
idx += INDEX_ADJUST[n & 7]
idx = 0 if idx < 0 else (48 if idx > 48 else idx)
out.append(signal)
return out
def encode(samples, variant="shift"):
"""12-bit signed samples -> nibbles.
The nibble is chosen by EXHAUSTIVE SEARCH over all sixteen, minimising the
reconstruction error of this sample. That is greedy rather than optimal --
a nibble also moves the step index, so a locally worse choice can pay later
-- but it is what a chip-matched encoder is expected to do and it costs
nothing offline. The decoder is run INSIDE the loop, so the encoder can
never drift away from what the decoder will reconstruct.
"""
signal, idx, out = 0, 0, bytearray()
for s in samples:
step = STEP[idx]
best, best_err = 0, None
for n in range(16):
v = signal + delta(n, step, variant)
v = SAMPLE_MIN if v < SAMPLE_MIN else (SAMPLE_MAX if v > SAMPLE_MAX else v)
err = (v - s) ** 2
if best_err is None or err < best_err:
best, best_err = n, err
signal += delta(best, step, variant)
signal = SAMPLE_MIN if signal < SAMPLE_MIN else (
SAMPLE_MAX if signal > SAMPLE_MAX else signal)
idx += INDEX_ADJUST[best & 7]
idx = 0 if idx < 0 else (48 if idx > 48 else idx)
out.append(best)
return bytes(out)
def pack(nibbles):
"""Nibbles -> bytes, HIGH NIBBLE FIRST. An odd count pads with a 0 nibble,
which is the quietest one the format has (delta = step/8)."""
n = bytes(nibbles)
if len(n) & 1:
n += b"\0"
return bytes((n[i] << 4) | n[i + 1] for i in range(0, len(n), 2))
def unpack(data, count=None):
out = bytearray()
for b in data:
out.append(b >> 4)
out.append(b & 15)
return bytes(out[:count] if count is not None else out)
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Extract the audio of a Blu-ray window as mono PCM at an MSM6258 sample rate.
The video side of this window is tools/encoder/extract.py; the arguments mean
the same things and are meant to be given the same values, because an audio
stream that is not the same seconds as the frames is not this project's audio.
The disc is AC-3 5.1 at 48 kHz. The arcade original is MONO, so this downmixes
-- ffmpeg's default matrix, dialogue from the centre channel included -- and
resamples to the chip's rate. Nothing here shapes, gates or normalises the
level: what the ADPCM encoder is handed is what the disc has, so that the SNR
it reports is the codec's and not a gain stage's.
"""
import getpass, os, subprocess, sys
BDROM = os.environ.get("DLX_BDROM") or f"/media/{getpass.getuser()}/BDROM"
STREAM_DIR = f"{BDROM}/BDMV/STREAM"
# 8 MHz / {512, 768, 1024}. The chip has no other rates and 15,625 is the one
# every budget in this project is written against (FINDINGS 52).
RATES = {15625: 512, 10417: 768, 7813: 1024}
def extract(stream, out, rate=15625, start=None, dur=None):
if rate not in RATES:
raise SystemExit(f"{rate} is not an MSM6258 rate: {sorted(RATES)}")
src = f"{STREAM_DIR}/{stream}.m2ts"
cmd = ["ffmpeg", "-v", "error"]
if start is not None: cmd += ["-ss", str(start)]
if dur is not None: cmd += ["-t", str(dur)]
cmd += ["-i", src, "-vn", "-ac", "1", "-ar", str(rate),
"-f", "s16le", "-acodec", "pcm_s16le", out, "-y"]
subprocess.check_call(cmd)
n = os.path.getsize(out) // 2
print(f"{stream}: {n} samples @ {rate} Hz mono = {n/rate:.3f} s -> {out}")
return n
if __name__ == "__main__":
# extract_audio.py <stream> <out.raw> [rate] [start_s] [dur_s]
stream, out = sys.argv[1], sys.argv[2]
rate = int(sys.argv[3]) if len(sys.argv) > 3 else 15625
start = float(sys.argv[4]) if len(sys.argv) > 4 else None
dur = float(sys.argv[5]) if len(sys.argv) > 5 else None
extract(stream, out, rate, start, dur)