#!/usr/bin/env python3 """Build the nibble stream that asks the MSM6258 which decoder it is. ROADMAP P6a. WHAT HAS TO BE DISCRIMINATED, and it is four things rather than the one FINDINGS 65 named: 1. DELTA FORMULA -- 'shift' (ffmpeg's adpcm_ima_oki) against 'terms' (the datasheet's per-term truncation). Worth 25 dB (65.2). 2. NIBBLE ORDER -- which half of a byte handed to the data register is played FIRST. 65.1 measured 'high' AGAINST FFMPEG, which is a fact about the VOX file convention and not about a chip's data register. 3. THE CLAMP -- the accumulator saturates somewhere, and where is inside the recursion, so it is not an output scaling that can be undone. 4. THE INITIAL ACCUMULATOR at the instant of PLAY. The stream is in three parts and each part exists for a reason: PROLOGUE, 16 zero nibbles. Nibble 0 moves the step index DOWN, so it stays pinned at 0 and the delta is a constant +2 under every candidate. That makes the prologue a RAMP that both formulas agree on, which is what absorbs the one thing this rig cannot control: how many times the chip consumes byte 0 before the channel delivers byte 1. The verifier reads that count off the capture instead of assuming it. SEGMENT A, a quiet sine, encoded by tools/encoder/adpcm.py itself. Amplitude 300 keeps it clear of even the 10-bit clamp, so A discriminates the FORMULA and the ORDER without the clamp confounding either. Using the shipping encoder rather than a hand-written pattern is deliberate: the nibbles the chip is asked about are the kind of nibbles it will be sent. SEGMENT B, loud bursts. It exists ONLY to cross the 10-bit clamp, which segment A is built never to reach, and it is last because a clamp is irreversible state and everything after it would be measuring segment B. """ import json, math, os, sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "encoder")) import adpcm PRO_NIB = 16 # prologue nibbles (byte 0 = $00, so a repeat costs nothing) A_SAMPLES = 1500 # segment A, one nibble each A_AMP = 300 # clear of the 10-bit clamp at 511 with room for the ramp A_HZ = 61.0 # ~256 samples a cycle at 15,625 Hz: many step indices RATE = 15625.0 BUF = 0x30000 # where the harness pushes the bytes OUT_BIN = "tmp/adpcm_data.bin" OUT_META = "tmp/adpcm_meta.lua" OUT_SEQ = "tmp/adpcm_seq.json" # THE TRIGGER, and it is here because the first cut of this file did not have # one and measured ONE differing sample in 1,676. The two formulas are # IDENTICAL whenever the step value is a multiple of 8: # # terms - shift = b2*floor(r/2) + b3*floor(r/4) - floor((4*b2+2*b3+1)*r/8) # # with r = step mod 8 and (b1,b2,b3) the nibble's low three bits. It is zero # for r = 0, and the step table STARTS at 16. A quiet signal never moves the # step index off its floor, so a probe made of quiet nibbles asks the chip a # question that has the same answer either way. # # nibble 4 at step 16: delta 18 under both, and it moves the index to 2 # nibble 3 at step 19: shift 16, terms 15 <- the two states part company # # After that they never rejoin, because the delta is added to a running # predictor -- so ONE two-nibble trigger converts the rest of the stream into # discriminating evidence. That is the same recursion 65.2 priced at 25 dB, # used deliberately instead of suffered. TRIGGER = [4, 3] def segment_a(): """The trigger, then a sine encoded by the shipping encoder. The model the sine is encoded under does not matter for discrimination -- once the trigger has parted the two states, any nibble stream keeps them apart -- so the defaults are used and the choice is recorded rather than tuned. Using the shipping encoder rather than a hand-written pattern is the point: the nibbles the chip is asked about are the kind of nibbles it will be sent.""" sig = [int(round(A_AMP * math.sin(2 * math.pi * A_HZ * i / RATE))) for i in range(A_SAMPLES)] return TRIGGER + list(adpcm.encode(sig, "shift")) def segment_b(): """Loud, and alternating in sign so the step index does not simply pin: 40 up, 40 down, twice. Under a 10-bit accumulator this saturates; under a 12-bit one it does not, and that difference is the whole point of it.""" return ([7] * 40 + [15] * 40) * 2 def main(): core = segment_a() + segment_b() nibs = [0] * PRO_NIB + core data = adpcm.pack(nibs, "high") # HIGH first: the encoder's convention, # which is one of the things on trial os.makedirs("tmp", exist_ok=True) open(OUT_BIN, "wb").write(data) # HOW MUCH DISCRIMINATING POWER IS IN IT, counted rather than asserted. A # probe that cannot separate two candidates reports a match against both and # a gate that did not count this would call that a result. ref = adpcm.decode(nibs, "shift", init=-2, bits=10) axes = {} for name, kw in (("formula", dict(variant="terms")), ("order", dict(order="low")), ("clamp", dict(bits=12)), ("init", dict(init=0))): order = kw.pop("order", "high") n2 = ([0] * PRO_NIB + list(adpcm.unpack(data, len(nibs), order))[PRO_NIB:]) \ if order != "high" else nibs n2 = list(adpcm.unpack(data, len(nibs), order)) alt = adpcm.decode(n2, kw.get("variant", "shift"), init=kw.get("init", -2), bits=kw.get("bits", 10)) d = sum(1 for a, b in zip(ref, alt) if a != b) axes[name] = d seq = {"nibbles": nibs, "core": core, "pro": PRO_NIB, "bytes": len(data), "buf": BUF, "axes": axes, "a_samples": A_SAMPLES, "a_amp": A_AMP, "a_hz": A_HZ} json.dump(seq, open(OUT_SEQ, "w")) with open(OUT_META, "w") as f: f.write("return {\n") f.write(f" buf = 0x{BUF:X},\n") f.write(f" nbytes = {len(data)},\n") f.write(f" nnibs = {len(nibs)},\n") f.write("}\n") print(f" probe stream: {len(nibs)} nibbles = {len(data)} B " f"= {len(nibs)/RATE*1000:.1f} ms at 15,625 Hz") print(f" prologue {PRO_NIB} zero nibbles, segment A {len(segment_a())} " f"(sine {A_AMP} @ {A_HZ} Hz), segment B {len(segment_b())} (loud)") print(" DISCRIMINATING POWER -- samples that change when ONE axis is " "flipped away from MAME's own model:") for k, v in axes.items(): print(f" {k:8s} {v:5d} of {len(ref)}") if __name__ == "__main__": main()