Files
Dragon-s-Lair-X68k/tools/encoder/adpcm.py
T
prosolis 6dd3fb3597 Ask the chip which decoder it is, and find four wrong axes where one was expected
ROADMAP P6a, on the machine. 68000 code programs HD63450 channel 3 with the
IPL ROM's own ADPCM bytes -- dual address, 8-bit port, cycle steal, external
request -- and feeds the MSM6258 a designed 1,678-nibble stream at the chip's
own pace: 839 B in 0.1074 s = 7,811.4 B/s against the format's 7,812.5, CER=$00.
That transport is P6b's, not scaffolding.

Sixteen candidate decoder models, three capture decimations and a searched
prologue are fitted to MAME's capture. Exactly one reproduces it sample-exact
over all 1,678 samples, and every axis carries a negative control: flip it
alone and the closest survivor disagrees on 826, 1,504, 156 and 1,522 samples.

The chip runs 'terms', takes the LOW nibble of a byte first, clamps the
accumulator at 10 bits and starts it at -2. tools/encoder/adpcm.py defaulted to
the opposite of all four, and 65.2 named the wrong axis as the risk: the delta
formula is worth -2.88 dB and the NIBBLE ORDER is worth -25.74 dB. 65.1's "high
first, measured" was a measurement of ffmpeg, i.e. of the VOX file convention,
which is a different question from what a chip does with a byte in its data
register.

The 10-bit clamp is free on the Singe window and only because that window peaks
at 435 of 511 -- 1.4 dB of headroom on a -13.4 dBFS passage, 12.1 dB below where
the encoder was clamping, and inside the recursion. So the audio level is an
open choice again, downward, and the loudest passage on the disc is unmeasured.

Session 33's silence had two ordinary causes: the PPI's port C is an input until
control word $92 says otherwise, and $01 is COMMAND_STOP. And a rig fact worth
the space: the 8 MHz ADPCM clock is CT1 in the YM2151's $1B, delivered on the
sound system's schedule rather than at the store, so a transfer started in the
same breath as the setup plays its first ~17 ms at the old clock and no model
fits a stream that changed rate part way through.

Name the layer: this is MAME 0.277's okim6258 device model measured end to end
through the machine's real transport. It settles the rig and not the silicon.
Also struck: 64.4's "no MAME source tree is on this machine" -- there is none on
disk, but the machine has network and the upstream tag fetches.

check.sh ALL GREEN before (tmp/check_s34_start.log) and after
(tmp/check_s34_end.log), with one new stage.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-25 09:50:03 -07:00

170 lines
7.6 KiB
Python

#!/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")
# ---------------------------------------------------------------------------
# THE THREE AXES THAT WERE FIXED CONSTANTS UNTIL SESSION 34, and every one of
# them turned out to be a real choice that a decoder can get wrong. FINDINGS 65
# priced the `variant` axis at 25 dB and left the other three unnamed; MAME's
# okim6258 disagrees with this file on ALL THREE, so they are parameters now and
# tools/bench/adpcm_run.sh measures which values the emulated chip runs.
#
# ORDER which nibble of a byte is played FIRST. 'high' is the Dialogic VOX
# file convention and is what ffmpeg's adpcm_ima_oki reads, which is
# what 65.1 measured. That is a fact about a FILE FORMAT. What the
# chip does with a byte handed to its data register is a different
# question and MAME answers it 'low'.
# INIT the accumulator at the instant the chip is told to PLAY. This file
# started it at 0; MAME's okim6258 resets it to -2.
# BITS where the accumulator CLAMPS. This file clamped at the 12-bit ADPCM
# word; the MSM6258's own D/A is 10-bit and MAME clamps there, INSIDE
# the recursion, so it is not a post-hoc output scaling.
#
# Defaults are unchanged, so tools/bench/verify_adpcm.py still measures exactly
# what it measured in session 33: ffmpeg's decoder, high nibble first.
ORDERS = ("high", "low")
# WHAT THE MACHINE'S OWN CHIP DOES, MEASURED -- tools/bench/adpcm_run.sh, one
# model of sixteen reproducing 1,678 consecutive samples of a MAME capture
# sample-exact, with a negative control on every axis (FINDINGS 66). It is a
# measurement of MAME's device model driven through the real transport, not of
# an MSM6258; the silicon is still a hardware item.
#
# THE DEFAULTS ABOVE ARE DELIBERATELY *NOT* THESE. The defaults are ffmpeg's
# adpcm_ima_oki, because tools/bench/verify_adpcm.py's whole value is that it
# checks this file against an independent implementation, and a default that
# had drifted to match the thing under test would end that. Anything that
# ENCODES FOR THE MACHINE passes CHIP explicitly.
CHIP = dict(variant="terms", order="low", bits=10, init=-2)
def clamp_bounds(bits):
"""The accumulator's clamp, as MAME's okim6258 computes it: max = 2^(b-1)-1,
min = -2^(b-1). Note it is NOT symmetric, and the asymmetry is load-bearing
on a signal that saturates."""
return -(1 << (bits - 1)), (1 << (bits - 1)) - 1
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", init=0, bits=12):
"""Nibbles -> signed samples. State is (signal, step index); the step index
is 0 at the start of a stream and `init` is where the accumulator starts."""
lo, hi = clamp_bounds(bits)
signal, idx, out = init, 0, []
for n in nibbles:
signal += delta(n, STEP[idx], variant)
signal = lo if signal < lo else (hi if signal > hi 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", init=0, bits=12):
"""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.
"""
lo, hi = clamp_bounds(bits)
signal, idx, out = init, 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 = lo if v < lo else (hi if v > hi 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 = lo if signal < lo else (hi if signal > hi 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, order="high"):
"""Nibbles -> bytes. `order` names which nibble of a byte is played FIRST;
'high' is the VOX file convention. An odd count pads with a 0 nibble, which
is the quietest one the format has (delta = step/8)."""
if order not in ORDERS:
raise ValueError(f"unknown nibble order {order!r}")
n = bytes(nibbles)
if len(n) & 1:
n += b"\0"
if order == "high":
return bytes((n[i] << 4) | n[i + 1] for i in range(0, len(n), 2))
return bytes((n[i + 1] << 4) | n[i] for i in range(0, len(n), 2))
def unpack(data, count=None, order="high"):
if order not in ORDERS:
raise ValueError(f"unknown nibble order {order!r}")
out = bytearray()
for b in data:
if order == "high":
out.append(b >> 4); out.append(b & 15)
else:
out.append(b & 15); out.append(b >> 4)
return bytes(out[:count] if count is not None else out)