data_block_len() rejected any `40 NN` block with NN > 0x08. That guard had
no evidence behind it: every corpus available when it was written used only
NN in {1,2,3,4,8}, so it was never exercised. Loud UM12947 events use NN of
12, 16, 20 ... up to 196.
Because walk_body/run stop at the first unrecognised tag rather than
raising, rejecting those blocks surfaced as silently short channels -- e.g.
Tran 1812 / Vert 2132 / Long 2324 on a file whose export carries 2324 for
all three. The real bound is the buffer; the caller additionally clamps to
the record end.
Verified against Thor's own CSV exports for UM12947 (2025-07-14 .. 09-25,
167 waveforms, supplied as CSV.zip):
length mismatches 22 -> 0
per-sample exact 1,476,242 / 1,476,249
These are NOT truncated recordings, which was the competing hypothesis --
the exports carry the full sample count.
tests/test_waveform_codec.py asserted the cap as intended behaviour. That
assertion encoded an assumption, not a verified fact, and is replaced with
one pinning the opposite plus the evidence.
Across all three ground-truth corpora: 459 waveform files,
3,807,158 / 3,807,165 samples exact. Production IDFW is now 575/575 with
zero truncations and zero decode failures (median PPV error -0.0007% across
8 units). Series-3 re-verified unchanged at 14,338/14,338.
The 7 residual samples each differ by one 4th-decimal tick and are Thor's
own rounding: intersecting the per-sample rounding constraints over that
corpus is infeasible (binding pair contradict by 2.3e-11, 7e-5 relative),
so no single linear LSB reproduces every printed value. _GEO_LSB_IPS is
already pinned to ~1e-11; do not retune it to chase these.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
949 lines
42 KiB
Python
949 lines
42 KiB
Python
"""
|
||
waveform_codec.py — block-walker and verified decoder for the MiniMate Plus
|
||
waveform-file body.
|
||
|
||
FULLY DECODED 2026-05-11. Every block type, every channel, and the
|
||
channel-rotation rule are verified byte-exact against BW's ASCII export
|
||
across the 9-event fixture bundle (47,364 ADC samples, zero errors).
|
||
|
||
The Blastware waveform-file body — the bytes between the 21-byte STRT
|
||
record and the 26-byte file footer — is a tagged variable-length block
|
||
stream with a custom delta + RLE codec. (Not raw int16 LE, which was
|
||
the historical wrong assumption that produced ±32K noise on every event.)
|
||
|
||
Current status:
|
||
|
||
- Block framing: ✅ solved (5 block types and lengths all confirmed)
|
||
- Per-channel decode: ✅ solved (Tran / Vert / Long / MicL all byte-exact)
|
||
- Channel rotation: ✅ Tran → Vert → Long → MicL per segment
|
||
- Segment header: ✅ fully decoded (anchor pair + prev-channel extension)
|
||
- 30 NN packed-delta block: ✅ NN × 12-bit signed deltas in NN/4 groups
|
||
- MicL → dB(L) conversion: ✅ ``mic_count_to_db`` matches BW display
|
||
- Production wiring: ✅ ``client.py:_decode_a5_waveform`` uses the new
|
||
codec (via ``decode_a5_frames``). ``.h5`` sidecars now render
|
||
correctly.
|
||
|
||
Known limitations:
|
||
|
||
- Walker stops early on the loudest events (SP0, SS0, SV0, event-b) at
|
||
some mid-segment edge cases not yet fully characterized. Every
|
||
sample reached IS correct; the walker just doesn't reach all of
|
||
them yet. The cleanly-decoded subset is still ~5000–15000 samples
|
||
per loud event.
|
||
|
||
────────────────────────────────────────────────────────────────────────────
|
||
Body layout (CONFIRMED 2026-05-11 against 8 fixture events)
|
||
────────────────────────────────────────────────────────────────────────────
|
||
|
||
[7-byte preamble] [stream of tagged blocks] [trailer]
|
||
|
||
The preamble is always exactly 7 bytes:
|
||
|
||
body[0:3] = 00 02 00 magic
|
||
body[3:5] = Tran[0] int16 BE in 16-count units (LSB = 0.005 in/s)
|
||
body[5:7] = Tran[1] int16 BE in 16-count units
|
||
|
||
(Earlier drafts of this module described a "7-or-9-byte preamble";
|
||
that was wrong — single-shot and continuous events both use 7 bytes.
|
||
The "extra 2 bytes" on continuous events were the first ``00 NN`` RLE
|
||
marker, not part of the preamble.)
|
||
|
||
Block types and lengths (all confirmed):
|
||
|
||
| Tag | Length | Meaning |
|
||
|----------|-----------------------|----------------------------------------|
|
||
| ``10 NN``| NN/2 + 2 bytes | 4-bit nibble deltas (2 per byte; high |
|
||
| | | nibble first; signed 0..7 / 8..F = -8..-1)|
|
||
| ``20 NN``| NN + 2 bytes | int8 signed deltas (1 per byte) |
|
||
| ``00 NN``| 2 bytes | RLE: append NN copies of current value |
|
||
| ``30 NN``| NN*2 in data, NN*4 | Unknown content. Only in loud events. |
|
||
| | in trailer | |
|
||
| ``40 02``| 20 bytes (fixed) | Segment header |
|
||
|
||
NN is always a multiple of 4.
|
||
|
||
────────────────────────────────────────────────────────────────────────────
|
||
Tran channel, segment 0 (CONFIRMED 2026-05-11)
|
||
────────────────────────────────────────────────────────────────────────────
|
||
|
||
Segment 0 — everything before the first ``40 02`` segment header — encodes
|
||
Tran samples only. Starting from preamble anchors Tran[0] and Tran[1],
|
||
each subsequent block contributes to the running Tran value:
|
||
|
||
10 NN → append NN deltas (4-bit signed nibbles)
|
||
20 NN → append NN deltas (int8 signed bytes)
|
||
00 NN → append NN copies of the current value (RLE zeros)
|
||
40 02 → segment 0 ends; multi-segment continuation is open
|
||
|
||
This decodes the first 482–510 samples of Tran for each event with zero
|
||
errors against BW's ASCII export. The exact segment-0 sample count
|
||
varies per event (it's bounded by a fixed device-flash byte budget, not
|
||
a fixed sample count — quiet events fit more samples because zero
|
||
deltas pack into ``00 NN`` markers compactly).
|
||
|
||
Implementation: :func:`decode_tran_initial`.
|
||
|
||
────────────────────────────────────────────────────────────────────────────
|
||
Segment header (40 02, 20 bytes total)
|
||
────────────────────────────────────────────────────────────────────────────
|
||
|
||
The 18-byte payload of the ``40 02`` block:
|
||
|
||
| Offset | Field | Status |
|
||
|-----------|---------------------------------------------|-------------|
|
||
| [0:2] | T_delta at first sample of new segment | ✅ confirmed|
|
||
| | (int16 BE, in 16-count units) | |
|
||
| [2:4] | Likely T_delta at sample seg_start+1 | 🟡 likely |
|
||
| [4:6] | Unknown (varies; possibly checksum) | ❓ open |
|
||
| [6:8] | Byte length to next segment header − 2 | ✅ confirmed|
|
||
| | (uint16 BE; useful for walker pre-scan) | |
|
||
| [8:12] | Monotonic uint32 LE counter | ✅ confirmed|
|
||
| | (starts ~0x47, increments by 1 per segment) | |
|
||
| [12:14] | Constant ``02 00`` | ✅ confirmed|
|
||
| [14:18] | Unknown 4-byte field | ❓ open |
|
||
|
||
────────────────────────────────────────────────────────────────────────────
|
||
What breaks the multi-segment decoder (the main open question)
|
||
────────────────────────────────────────────────────────────────────────────
|
||
|
||
After segment 0 ends and the segment header T_delta is consumed,
|
||
applying segment 1's blocks as Tran continuation produces values that
|
||
diverge from truth by sample ~512. The block structure inside segment
|
||
1 is IDENTICAL to segment 0 (same alternating 10 NN / 00 NN pattern),
|
||
and the delta budget matches the segment size exactly (V70 segment 1
|
||
has 264 nibble-deltas + 244 RLE zeros = 508 = the segment's sample
|
||
count). But the cumulative is wrong.
|
||
|
||
The strongest unverified hypothesis is that segments rotate channels:
|
||
|
||
segment 0 → Tran samples 0..509
|
||
segment 1 → Vert samples 0..507
|
||
segment 2 → Long samples 0..507
|
||
segment 3 → Mic samples 0..507
|
||
segment 4 → Tran samples 510..N (continuation)
|
||
...
|
||
|
||
This is consistent with the segment-1 block sums net-to-near-zero in
|
||
V70 (where all 4 channels are near zero) and with the per-segment delta
|
||
budget matching the segment size for a single channel. It is NOT yet
|
||
verified because the per-segment channel anchor isn't pinned down in
|
||
the segment header — bytes [4:6] and [14:18] of the header are still
|
||
open and probably encode V/L/M anchors.
|
||
|
||
See ``docs/waveform_codec_re_status.md`` for the current working notes
|
||
and the suggested next experiment ("segment-channel scoring analyzer").
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from typing import List, Optional, Tuple
|
||
|
||
|
||
@dataclass
|
||
class WaveformBlock:
|
||
"""One tagged block parsed out of a Blastware waveform-file body."""
|
||
offset: int # byte offset into body
|
||
tag_hi: int # first tag byte (0x10 / 0x20 / 0x00 / 0x30 / 0x40)
|
||
tag_lo: int # second tag byte (NN)
|
||
data: bytes # block payload (excludes the 2-byte tag)
|
||
length: int # total block length on the wire (includes the tag)
|
||
|
||
@property
|
||
def kind(self) -> str:
|
||
return f"{self.tag_hi:02x} {self.tag_lo:02x}"
|
||
|
||
|
||
def find_data_start(body: bytes) -> int:
|
||
"""Auto-detect the offset of the first data block.
|
||
|
||
The body starts with a 7-byte preamble (magic ``00 02 00`` + two int16 BE
|
||
Tran anchors). After that, the data section starts with a tag — usually
|
||
``10 NN`` or ``20 NN``, but quiet events may begin with a ``00 NN`` RLE
|
||
marker. We return the offset of the first recognized tag.
|
||
"""
|
||
# Try fixed offset 7 first (canonical preamble length).
|
||
if len(body) >= 9:
|
||
b, nn = body[7], body[8]
|
||
# Accept the same tag vocabulary ``walk_body`` accepts, including the
|
||
# wide-NN forms (``0X``/``1X``/``2X``) and the variable-width ``40 NN``
|
||
# segment header.
|
||
if ((b & 0xF0) in (0x00, 0x10, 0x20) and nn % 4 == 0
|
||
and ((b & 0x0F) != 0 or 0 < nn <= 0xFC)) \
|
||
or (b == 0x30 and nn % 4 == 0 and 0 < nn <= 0xFC) \
|
||
or (b == 0x40 and 0 < nn <= 0x08) \
|
||
or is_tagless_segment_header(body, 7):
|
||
return 7
|
||
# Fall back to scanning the first 20 bytes.
|
||
for i in range(min(20, len(body) - 1)):
|
||
b = body[i]
|
||
nn = body[i + 1]
|
||
if b in (0x10, 0x20) and nn % 4 == 0 and 0 < nn <= 0xFC:
|
||
return i
|
||
return -1
|
||
|
||
|
||
# Channel-id byte carried in every segment header. Previously mis-read as a
|
||
# "monotonic uint32 LE counter"; it is really ``[channel][00][00][segment]``.
|
||
# Verified 2026-08-25 on 1697/1697 segment headers across the ground-truth
|
||
# corpus with zero disagreements against the decoded channel rotation.
|
||
SEGMENT_CHANNEL_IDS = {0x46: "Tran", 0x47: "Vert", 0x48: "Long", 0x49: "MicL"}
|
||
|
||
# A tagless segment header: the 14-byte tail of a ``40 NN`` header with no tag
|
||
# and no previous-channel continuation deltas (the NN=0 case).
|
||
_TAGLESS_HEADER_LEN = 14
|
||
|
||
|
||
def is_tagless_segment_header(body: bytes, i: int) -> bool:
|
||
"""True if a bare 14-byte segment header starts at *i*.
|
||
|
||
Layout ``[field2:2][len_to_next:2][channel_id:4][marker:2][anchors:4]``.
|
||
The discriminator is the 6 bytes at ``[4:10]``: a known channel id, two
|
||
zero bytes, a small segment index, and the ``01 00`` / ``02 00`` marker.
|
||
"""
|
||
if i + _TAGLESS_HEADER_LEN > len(body):
|
||
return False
|
||
return (body[i + 4] in SEGMENT_CHANNEL_IDS
|
||
and body[i + 5] == 0x00 and body[i + 6] == 0x00
|
||
and body[i + 8] in (0x01, 0x02) and body[i + 9] == 0x00)
|
||
|
||
|
||
def walk_body(body: bytes, start: Optional[int] = None) -> List[WaveformBlock]:
|
||
"""Walk the tagged-block sequence starting at *start* (auto-detected by default).
|
||
|
||
Stops when an unrecognized tag is encountered or end of body is reached.
|
||
Returned blocks are in stream order.
|
||
"""
|
||
if start is None:
|
||
start = find_data_start(body)
|
||
if start < 0:
|
||
return []
|
||
|
||
blocks: List[WaveformBlock] = []
|
||
i = start
|
||
while i + 1 < len(body):
|
||
t0 = body[i]
|
||
t1 = body[i + 1]
|
||
if t0 == 0x10 and t1 % 4 == 0 and 0 < t1 <= 0xFC:
|
||
length = t1 // 2 + 2
|
||
elif (t0 & 0xF0) == 0x10 and (t0 & 0x0F) != 0 and t1 % 4 == 0:
|
||
# Wide-NN nibble block: ``1X NN`` where X is the high nibble of a
|
||
# 12-bit NN value. NN = ((t0 & 0x0F) << 8) | t1. Block length
|
||
# = NN/2 + 2 bytes (NN nibble deltas, same as ``10 NN`` semantics
|
||
# but with NN > 0xFC). Confirmed 2026-05-11 in SP0 segment 12
|
||
# where V continuation uses ``11 90`` = NN=0x190=400.
|
||
wide_nn = ((t0 & 0x0F) << 8) | t1
|
||
length = wide_nn // 2 + 2
|
||
elif t0 == 0x20 and t1 % 4 == 0 and 0 < t1 <= 0xFC:
|
||
length = t1 + 2
|
||
elif (t0 & 0xF0) == 0x20 and (t0 & 0x0F) != 0 and t1 % 4 == 0:
|
||
# Wide-NN int8 block: ``2X NN`` extends NN to 12 bits the same way.
|
||
wide_nn = ((t0 & 0x0F) << 8) | t1
|
||
length = wide_nn + 2
|
||
elif (t0 & 0xF0) == 0x00 and t1 % 4 == 0:
|
||
# ``00 NN`` RLE zero-delta run, plus its wide form ``0X NN``
|
||
# (X != 0) which extends NN to 12 bits exactly like ``1X``/``2X``:
|
||
# NN = ((t0 & 0x0F) << 8) | t1. A narrow run maxes out at
|
||
# NN=0xFC, so quiet stretches longer than 252 samples must use
|
||
# the wide form. Confirmed 2026-08-25 against six production
|
||
# events (e.g. ``01 0c`` = 268 repeats in K558LKOF.460W).
|
||
length = 2
|
||
elif t0 == 0x30 and t1 % 4 == 0 and 0 < t1 <= 0xFC:
|
||
# Data-section ``30 NN`` blocks carry NN 12-bit signed deltas packed
|
||
# as NN/4 groups of (2-byte high-nibble field + 4 × int8 low byte).
|
||
# Length = NN/4 × 6 + 2 = NN × 1.5 + 2 (= 8 for NN=4, 14 for NN=8,
|
||
# 20 for NN=12, etc.). Confirmed 2026-05-11 by full-decoder
|
||
# verification against BW ASCII export.
|
||
#
|
||
# Trailer-section ``30 NN`` blocks have a different length formula
|
||
# (NN × 4 = 32 for NN=8 in trailers). We try the data-section
|
||
# length first and fall back to the trailer length if needed.
|
||
cand_data = t1 * 3 // 2 + 2
|
||
cand_trailer = t1 * 4
|
||
if (i + cand_data < len(body) - 1
|
||
and body[i + cand_data] in (0x10, 0x20, 0x00, 0x30, 0x40)):
|
||
length = cand_data
|
||
else:
|
||
length = cand_trailer
|
||
elif t0 == 0x40 and 0 < t1 <= 0x08:
|
||
# ``40 NN`` segment header. NN is the number of int16 BE
|
||
# continuation deltas the header carries for the PREVIOUS
|
||
# channel, so the header grows with NN:
|
||
# length = 2 (tag) + 2*NN (deltas) + 14 (fixed tail)
|
||
# ``40 02`` (20 bytes) dominates, but ``40 01`` (18) and
|
||
# ``40 03`` (22) both occur in production files. Confirmed
|
||
# 2026-08-25; the constant ``02 00`` marker moves with NN too
|
||
# (see :func:`parse_segment_header`).
|
||
length = 2 * t1 + 16
|
||
elif is_tagless_segment_header(body, i):
|
||
# Segment header with no ``40 NN`` tag (NN=0 — the previous channel
|
||
# needed no continuation deltas). Emit it as a synthetic ``40 00``
|
||
# block whose ``data`` is the whole 14-byte record, so the nd=0
|
||
# offsets in :func:`decode_waveform_v2` line up unchanged.
|
||
blocks.append(WaveformBlock(
|
||
offset=i, tag_hi=0x40, tag_lo=0x00,
|
||
data=bytes(body[i : i + _TAGLESS_HEADER_LEN]),
|
||
length=_TAGLESS_HEADER_LEN,
|
||
))
|
||
i += _TAGLESS_HEADER_LEN
|
||
continue
|
||
else:
|
||
# Unknown tag; stop. Caller can inspect ``i`` to see where.
|
||
break
|
||
|
||
if i + length > len(body):
|
||
break
|
||
|
||
data = bytes(body[i + 2 : i + length])
|
||
blocks.append(WaveformBlock(offset=i, tag_hi=t0, tag_lo=t1, data=data, length=length))
|
||
i += length
|
||
|
||
return blocks
|
||
|
||
|
||
def split_segments(blocks: List[WaveformBlock]) -> List[List[WaveformBlock]]:
|
||
"""Group consecutive blocks into segments separated by ``40 02`` headers.
|
||
|
||
The first segment is whatever runs before the first ``40 02`` header
|
||
(typically the "segment 0" preamble data after the body preamble).
|
||
Subsequent segments start with a ``40 02`` block, then have their
|
||
own data blocks until the next ``40 02``.
|
||
"""
|
||
segments: List[List[WaveformBlock]] = []
|
||
current: List[WaveformBlock] = []
|
||
for b in blocks:
|
||
if b.tag_hi == 0x40:
|
||
if current:
|
||
segments.append(current)
|
||
current = [b]
|
||
else:
|
||
current.append(b)
|
||
if current:
|
||
segments.append(current)
|
||
return segments
|
||
|
||
|
||
def parse_segment_header(block: WaveformBlock) -> Optional[dict]:
|
||
"""Decode the payload of a ``40 NN`` segment header.
|
||
|
||
NN (the tag's low byte) is the number of int16 BE continuation deltas
|
||
the header carries for the PREVIOUS channel, so every field after
|
||
those deltas shifts by ``2 * NN``. The payload is ``2 * NN + 14``
|
||
bytes. ``40 02`` is the common case; ``40 01`` and ``40 03`` also
|
||
occur in production files (confirmed 2026-08-25).
|
||
|
||
Returns a dict with the labelled fields, or None if *block* is not a
|
||
segment header or is too short.
|
||
"""
|
||
if block.tag_hi != 0x40 or block.tag_lo > 0x08:
|
||
return None
|
||
nd = block.tag_lo
|
||
if len(block.data) < 2 * nd + 14:
|
||
return None
|
||
p = block.data
|
||
counter = int.from_bytes(p[2 * nd + 4 : 2 * nd + 8], "little", signed=False)
|
||
return {
|
||
"n_prev_deltas": nd,
|
||
# ``nd`` int16 BE deltas extending the previous channel.
|
||
"prev_deltas": [
|
||
int.from_bytes(p[2 * k : 2 * k + 2], "big", signed=True)
|
||
for k in range(nd)
|
||
],
|
||
"field2": p[2 * nd : 2 * nd + 4], # 4-byte field, role unconfirmed
|
||
"counter": counter, # legacy: raw uint32 LE of the id field
|
||
"channel": SEGMENT_CHANNEL_IDS.get(p[2 * nd + 4]),
|
||
"segment_index": p[2 * nd + 7],
|
||
"marker": p[2 * nd + 8 : 2 * nd + 10], # always b"\x02\x00"
|
||
"anchors": [
|
||
int.from_bytes(p[2 * nd + 10 : 2 * nd + 12], "big", signed=True),
|
||
int.from_bytes(p[2 * nd + 12 : 2 * nd + 14], "big", signed=True),
|
||
],
|
||
}
|
||
|
||
|
||
def _s4(n: int) -> int:
|
||
"""Sign-extend a 4-bit value to signed int (0..7 → 0..7; 8..F → -8..-1)."""
|
||
return n if n < 8 else n - 16
|
||
|
||
|
||
def _i8(b: int) -> int:
|
||
"""Reinterpret an unsigned byte as signed int8."""
|
||
return b if b < 128 else b - 256
|
||
|
||
|
||
def decode_tran_initial(body: bytes) -> Optional[List[int]]:
|
||
"""
|
||
Decode the initial Tran-channel samples — VERIFIED 2026-05-11.
|
||
|
||
Returns Tran samples in **16-count units** (LSB = 0.005 in/s at Normal
|
||
range — the same quantization BW uses for its ASCII export). Returns
|
||
``None`` if the body cannot be parsed.
|
||
|
||
The decoded list extends from sample 0 through the end of segment 0
|
||
(= just before the first ``40 02`` segment header; ~510 sample-sets
|
||
for the events tested). Multi-segment decoding requires continuing
|
||
past the segment header — that's done by :func:`decode_tran_full`
|
||
when the per-segment rules are pinned down for all signal types.
|
||
|
||
Codec for segment 0 (CONFIRMED 2026-05-11 against 7 fixture events):
|
||
|
||
- Body bytes [0:3] are the magic ``00 02 00``.
|
||
- Body bytes [3:5] = ``Tran[0]`` as int16 BE in 16-count units.
|
||
- Body bytes [5:7] = ``Tran[1]`` as int16 BE in 16-count units.
|
||
- Data blocks (``10 NN`` or ``20 NN``) carry Tran deltas starting
|
||
at sample 2:
|
||
|
||
* ``10 NN``: NN nibbles = NN/2 bytes; each nibble is a 4-bit
|
||
signed delta (0..7 → 0..+7; 8..F → -8..-1). High nibble of
|
||
each byte comes first.
|
||
* ``20 NN``: NN int8 signed deltas (one delta per byte).
|
||
|
||
- ``00 NN`` blocks are run-length-encoded zero deltas: append NN
|
||
copies of the current cumulative Tran value (no change).
|
||
|
||
- ``30 NN`` blocks have not yet been decoded for content — they
|
||
appear in segment 0 of loud-from-start events (SS0, SV0) and
|
||
seem to signal a transition or special-case interpretation.
|
||
The walker steps over them but their data is ignored.
|
||
|
||
The walk stops at the first ``40 02`` segment header.
|
||
"""
|
||
if len(body) < 7 or body[0:3] != b"\x00\x02\x00":
|
||
return None
|
||
t0 = int.from_bytes(body[3:5], "big", signed=True)
|
||
t1 = int.from_bytes(body[5:7], "big", signed=True)
|
||
|
||
start = find_data_start(body)
|
||
if start < 0:
|
||
return [t0, t1]
|
||
|
||
out = [t0, t1]
|
||
cur = t1
|
||
for blk in walk_body(body, start):
|
||
if blk.tag_hi == 0x40:
|
||
# Segment boundary — stop. Multi-segment decode is decode_tran_full.
|
||
break
|
||
if blk.tag_hi == 0x10:
|
||
for byte in blk.data:
|
||
for nib in ((byte >> 4) & 0xF, byte & 0xF):
|
||
cur += _s4(nib)
|
||
out.append(cur)
|
||
elif blk.tag_hi == 0x20:
|
||
for byte in blk.data:
|
||
cur += _i8(byte)
|
||
out.append(cur)
|
||
elif blk.tag_hi == 0x00:
|
||
# RLE zero deltas: append NN copies of current Tran value.
|
||
for _ in range(blk.tag_lo):
|
||
out.append(cur)
|
||
# 30 NN: unknown content; skip.
|
||
return out
|
||
|
||
|
||
def decode_waveform_legacy(body: bytes) -> Optional[dict]:
|
||
"""
|
||
SUPERSEDED 2026-08-25 — the tag-dispatch / segment-header model.
|
||
|
||
Retained because ``micromate/idf_file.py`` trial-decodes Thor IDFW bodies
|
||
at many candidate offsets and keeps whichever yields the most samples;
|
||
the record-chain decoder returns None where this one returned garbage,
|
||
which shifts that heuristic's winner. Thor is pinned here until its own
|
||
body-offset search is reworked. Do not use for series-3.
|
||
|
||
Decode the body into per-channel sample arrays.
|
||
|
||
Status (2026-05-11 evening — channel-rotation hypothesis CONFIRMED):
|
||
segments rotate channels in fixed order **Tran → Vert → Long → MicL**.
|
||
Each channel-segment carries a 2-sample anchor pair in segment-header
|
||
bytes [14:18] (or in the body preamble for the initial Tran segment)
|
||
plus a stream of delta blocks for samples 2 onward.
|
||
|
||
Returns ``{"Tran": [...], "Vert": [...], "Long": [...], "MicL": [...]}``
|
||
with each channel's decoded samples in 16-count units (LSB = 0.005
|
||
in/s at Normal range). Returns ``None`` if the body cannot be
|
||
parsed.
|
||
"""
|
||
if len(body) < 7 or body[0:3] != b"\x00\x02\x00":
|
||
return None
|
||
|
||
channels = ["Tran", "Vert", "Long", "MicL"]
|
||
out: dict = {ch: [] for ch in channels}
|
||
|
||
# Initial Tran segment: preamble anchor pair + delta blocks before first 40 02.
|
||
t0 = int.from_bytes(body[3:5], "big", signed=True)
|
||
t1 = int.from_bytes(body[5:7], "big", signed=True)
|
||
out["Tran"].extend([t0, t1])
|
||
|
||
start = find_data_start(body)
|
||
if start < 0:
|
||
return out
|
||
|
||
blocks = walk_body(body, start)
|
||
seg_idx = [i for i, b in enumerate(blocks) if b.tag_hi == 0x40]
|
||
|
||
def apply_blocks(channel: str, anchor: int,
|
||
block_start: int, block_end: int) -> int:
|
||
"""Apply delta blocks [block_start, block_end) to *channel*'s sample
|
||
list, starting from *anchor*. Returns the final cumulative value."""
|
||
cur = anchor
|
||
for bi in range(block_start, block_end):
|
||
blk = blocks[bi]
|
||
if (blk.tag_hi & 0xF0) == 0x10:
|
||
# Both ``10 NN`` (NN ≤ 0xFC) and wide-NN ``1X NN`` (X != 0)
|
||
# are nibble-delta streams. The walker has already used the
|
||
# right length; here we just iterate the payload bytes.
|
||
for byte in blk.data:
|
||
for nib in ((byte >> 4) & 0xF, byte & 0xF):
|
||
cur += _s4(nib)
|
||
out[channel].append(cur)
|
||
elif (blk.tag_hi & 0xF0) == 0x20:
|
||
# ``20 NN`` and wide ``2X NN`` both carry int8 deltas.
|
||
for byte in blk.data:
|
||
cur += _i8(byte)
|
||
out[channel].append(cur)
|
||
elif (blk.tag_hi & 0xF0) == 0x00:
|
||
# RLE zero-delta run. Wide form ``0X NN`` carries the high
|
||
# nibble of a 12-bit NN in the tag byte, same as ``1X``/``2X``.
|
||
run = ((blk.tag_hi & 0x0F) << 8) | blk.tag_lo
|
||
for _ in range(run):
|
||
out[channel].append(cur)
|
||
elif blk.tag_hi == 0x30:
|
||
# 12-bit signed deltas, packed as NN/4 groups of 6 bytes each:
|
||
# bytes [0:2] = 16 bits = 4 × 4-bit high nibbles (MSB first)
|
||
# bytes [2:6] = 4 × int8 low bytes
|
||
# Each delta = sign_extend_12((high_nibble << 8) | low_byte).
|
||
# Confirmed 2026-05-11 against all 14 ``30 NN`` blocks in the
|
||
# bundled fixtures.
|
||
n_groups = blk.tag_lo // 4
|
||
for g in range(n_groups):
|
||
grp = blk.data[g * 6 : (g + 1) * 6]
|
||
if len(grp) < 6:
|
||
break
|
||
high_word = (grp[0] << 8) | grp[1]
|
||
for k in range(4):
|
||
nib = (high_word >> (12 - 4 * k)) & 0xF
|
||
v = (nib << 8) | grp[2 + k]
|
||
if v >= 0x800:
|
||
v -= 0x1000
|
||
cur += v
|
||
out[channel].append(cur)
|
||
# 40 02: should not occur in segment data.
|
||
return cur
|
||
|
||
# Initial Tran segment: deltas from start of body up to first 40 02 (or end).
|
||
first_seg = seg_idx[0] if seg_idx else len(blocks)
|
||
last_tran_value = apply_blocks("Tran", t1, 0, first_seg)
|
||
|
||
# Subsequent segments rotate channels. Each segment header carries:
|
||
# bytes [0:2] and [2:4] = 2 deltas extending the PREVIOUS channel
|
||
# bytes [14:16] and [16:18] = anchor pair for THIS segment's channel
|
||
#
|
||
# Rotation: V, L, M, T, V, L, M, T, ... (initial Tran segment is the
|
||
# implicit T in the cycle.)
|
||
rotation = ["Vert", "Long", "MicL", "Tran"]
|
||
# Track each channel's "running cumulative value" so we can apply the
|
||
# previous-channel extension deltas at every segment boundary.
|
||
last_value = {"Tran": last_tran_value, "Vert": None, "Long": None, "MicL": None}
|
||
|
||
prev_channel = "Tran"
|
||
for k, hi in enumerate(seg_idx):
|
||
header = blocks[hi]
|
||
# Channel comes from the header's own id byte, which is authoritative.
|
||
# The old rotation-by-position fallback is kept for headers whose id
|
||
# byte isn't one of the four known values — but a single missed or
|
||
# extra header would desync rotation and corrupt every later channel,
|
||
# which is exactly what tagless headers used to cause.
|
||
_nd = header.tag_lo
|
||
channel = None
|
||
if len(header.data) >= 2 * _nd + 8:
|
||
channel = SEGMENT_CHANNEL_IDS.get(header.data[2 * _nd + 4])
|
||
if channel is None:
|
||
channel = rotation[k % 4]
|
||
# ``40 NN``: NN int16 BE continuation deltas for the previous channel
|
||
# come first, so every later field shifts by 2*NN. NN is usually 2
|
||
# but 1 and 3 both occur (confirmed 2026-08-25).
|
||
nd = header.tag_lo
|
||
if len(header.data) < 2 * nd + 14:
|
||
continue
|
||
# Validate: real segment headers have the constant `02 00` marker
|
||
# right after the counter. Trailer/footer "40 NN" markers contain
|
||
# ASCII serial bytes or other non-header data there and would
|
||
# otherwise be mis-read as segment headers, adding spurious tail
|
||
# samples.
|
||
if header.data[2 * nd + 8 : 2 * nd + 10] != b"\x02\x00":
|
||
break
|
||
# Extend the PREVIOUS channel by NN more samples.
|
||
if last_value[prev_channel] is not None:
|
||
v = last_value[prev_channel]
|
||
for d in range(nd): # NB: not `k` — that's the segment index
|
||
v += int.from_bytes(
|
||
header.data[2 * d : 2 * d + 2], "big", signed=True
|
||
)
|
||
out[prev_channel].append(v)
|
||
last_value[prev_channel] = v
|
||
# Anchor pair for THIS segment's channel.
|
||
c0 = int.from_bytes(
|
||
header.data[2 * nd + 10 : 2 * nd + 12], "big", signed=True
|
||
)
|
||
c1 = int.from_bytes(
|
||
header.data[2 * nd + 12 : 2 * nd + 14], "big", signed=True
|
||
)
|
||
out[channel].extend([c0, c1])
|
||
# Apply delta blocks for this segment.
|
||
next_hi = seg_idx[k + 1] if k + 1 < len(seg_idx) else len(blocks)
|
||
last_value[channel] = apply_blocks(channel, c1, hi + 1, next_hi)
|
||
prev_channel = channel
|
||
|
||
return out
|
||
|
||
|
||
# ── ADC-scale conversion helpers ────────────────────────────────────────────
|
||
|
||
|
||
# Scaling factor: decode_waveform_v2 produces geo-channel samples in the BW
|
||
# display quantization (16-count units, LSB = 0.005 in/s at Normal range).
|
||
# The legacy consumer pipeline (sfm/event_hdf5.py) expects raw_samples in
|
||
# 1-count ADC units (× full_scale / 32768 → physical). To plug the new
|
||
# decoder in without rewriting consumers, multiply geo values by 16.
|
||
#
|
||
# Mic samples are already in raw ADC counts (decoded value 1 = 1 mic ADC count
|
||
# = -81.94 dB on the BW display). Mic values pass through unchanged.
|
||
_GEO_DECODER_TO_ADC = 16
|
||
|
||
|
||
def decoded_to_adc_counts(decoded: dict) -> dict:
|
||
"""Convert :func:`decode_waveform_v2` output to int16 ADC counts.
|
||
|
||
Geo channels are scaled by ×16 (decoder produces 16-count units,
|
||
consumer expects 1-count ADC). Mic is passed through as raw counts.
|
||
"""
|
||
if not decoded:
|
||
return {}
|
||
return {
|
||
"Tran": [v * _GEO_DECODER_TO_ADC for v in decoded.get("Tran", [])],
|
||
"Vert": [v * _GEO_DECODER_TO_ADC for v in decoded.get("Vert", [])],
|
||
"Long": [v * _GEO_DECODER_TO_ADC for v in decoded.get("Long", [])],
|
||
"MicL": list(decoded.get("MicL", [])),
|
||
}
|
||
|
||
|
||
def mic_count_to_db(count: int) -> float:
|
||
"""Convert a MicL ADC count to dB(L) for BW-display-compatible output.
|
||
|
||
Empirical formula (confirmed 2026-05-11 against V70 fixture: count=813
|
||
→ 140.1 dB; count=±1 → ±81.94 dB; count=±24 → ±109.5 dB):
|
||
|
||
dB = sign(count) × (81.94 + 20 × log10(|count|)) for |count| ≥ 1
|
||
dB = 0.0 for count == 0
|
||
|
||
The constant 81.94 corresponds to 10^(81.94/20) ≈ 12490 mic ADC counts
|
||
being the dB(L) reference level — almost certainly a calibration
|
||
constant from the device's mic.
|
||
"""
|
||
if count == 0:
|
||
return 0.0
|
||
sign = 1.0 if count > 0 else -1.0
|
||
return sign * (81.94 + 20.0 * math.log10(abs(count)))
|
||
|
||
|
||
# ── A5-frame entry point ────────────────────────────────────────────────────
|
||
|
||
|
||
def decode_a5_frames(a5_frames) -> Optional[dict]:
|
||
"""Decode a list of A5 (BULK_WAVEFORM_STREAM) frames into per-channel
|
||
int16 ADC samples.
|
||
|
||
Returns ``{"Tran": [...], "Vert": [...], "Long": [...], "MicL": [...]}``
|
||
with each channel's samples in **1-count ADC units** (the legacy
|
||
``event.raw_samples`` convention — multiply by ``full_scale / 32768``
|
||
to convert to physical units; for mic, use :func:`mic_count_to_db` or
|
||
a per-count psi factor).
|
||
|
||
Returns ``None`` if the frames cannot be parsed.
|
||
|
||
This is the wired-up production entry point. It:
|
||
1. Reconstructs the BW-binary body bytes from the A5 frames
|
||
(``blastware_file.extract_body_bytes``).
|
||
2. Runs the verified codec (``decode_waveform_v2``) on the body.
|
||
3. Converts to int16 ADC counts via :func:`decoded_to_adc_counts`.
|
||
"""
|
||
# Local import to avoid a cycle: blastware_file imports models and
|
||
# ultimately client.py imports waveform_codec.
|
||
from .blastware_file import extract_body_bytes
|
||
|
||
if not a5_frames:
|
||
return None
|
||
_strt, body, _footer = extract_body_bytes(a5_frames)
|
||
if not body:
|
||
return None
|
||
decoded = decode_waveform_v2(body)
|
||
if decoded is None:
|
||
return None
|
||
return decoded_to_adc_counts(decoded)
|
||
|
||
|
||
# ── Record-chain body model (CONFIRMED 2026-08-25) ──────────────────────────
|
||
#
|
||
# The body is NOT a flat tag-dispatch stream with ``40 NN`` segment headers.
|
||
# It is a chain of self-delimiting per-channel RECORDS:
|
||
#
|
||
# off+0 field2 uint16 purpose unknown (not a length, not a checksum)
|
||
# off+2 len uint16 BE next_record = off + 2 + len <- authoritative
|
||
# off+4 chan_id 0x46 Tran / 0x47 Vert / 0x48 Long / 0x49 MicL
|
||
# 0x06 = end of waveform stream
|
||
# off+5 0x00
|
||
# off+6 0x00
|
||
# off+7 segment index
|
||
# off+8 mode 2 bytes, a 3-valued enum (see below)
|
||
# off+10 anchors 2 x int16 BE, ABSOLUTE — present only when mode is 02 00
|
||
#
|
||
# Mode semantics, all ground-truth verified:
|
||
# 02 00 14-byte header; emit the 2 anchors, then blocks are CUMULATIVE deltas
|
||
# 01 00 10-byte header; no anchors; blocks carry ABSOLUTE sample values
|
||
# 00 03 10-byte header; NO TAGS AT ALL — the data section is raw 12-bit
|
||
# packed ABSOLUTE samples (6 bytes -> 4 samples)
|
||
#
|
||
# ``40 NN`` is an ordinary int16 BE DATA block (length 2*NN + 2), never a header.
|
||
# The previous model read it as a variable-width segment header of length
|
||
# 2*NN + 16, which is why walks drifted and channels came out unequal.
|
||
#
|
||
# Verified over the 1,388 series-3 waveform binaries in the production
|
||
# snapshot: the length chain terminates on a 0x06 record in 1,387 of them (the
|
||
# exception has an ambiguous footer, handled by the caller), and all four
|
||
# channels come out at identical length in 1,388/1,388 — against 156/1,388
|
||
# under the superseded model. Against the 75 events with a preserved
|
||
# Blastware ASCII export: sample-count exact 72/75 -> 75/75, fully exact
|
||
# 70/75 -> 73/75.
|
||
|
||
CHANNEL_IDS = {0x46: "Tran", 0x47: "Vert", 0x48: "Long", 0x49: "MicL"}
|
||
STREAM_END_ID = 0x06
|
||
|
||
MODE_DELTA = (0x02, 0x00)
|
||
MODE_ABSOLUTE = (0x01, 0x00)
|
||
MODE_RAW12 = (0x00, 0x03)
|
||
# Raw int16 BE absolute samples, 10-byte header, no tags — the same shape as
|
||
# MODE_RAW12 but two bytes per sample instead of 1.5. Found on Thor/Micromate
|
||
# segment-0 records (2026-09-10): a `len=1032` record carries exactly
|
||
# (1032 - 8) / 2 = 512 samples and reproduces Thor's own export 512/512
|
||
# exactly. Before this mode existed the record fell through the dispatch
|
||
# unhandled, so the channel silently lost its first 512 samples.
|
||
MODE_RAW16 = (0x00, 0x00)
|
||
_MODES = (MODE_DELTA, MODE_ABSOLUTE, MODE_RAW12, MODE_RAW16)
|
||
|
||
# Preambles whose leading data is untagged and therefore cannot be
|
||
# block-walked; find_first_record() must scan for the next record instead.
|
||
_UNTAGGED_MODES = (MODE_RAW12, MODE_RAW16)
|
||
|
||
|
||
def _u16(b: bytes, p: int) -> int:
|
||
return (b[p] << 8) | b[p + 1]
|
||
|
||
|
||
def _i16(b: bytes, p: int) -> int:
|
||
v = _u16(b, p)
|
||
return v - 0x10000 if v >= 0x8000 else v
|
||
|
||
|
||
def data_block_len(body: bytes, p: int) -> Tuple[Optional[int], Optional[int]]:
|
||
"""``(byte_length, n_samples)`` of the data block at *p*, or ``(None, None)``.
|
||
|
||
Data-section blocks only — there is no segment-header tag in this model.
|
||
``30 NN`` has no trailer-length fallback here; that fallback corrupted
|
||
records whose ``30 NN`` sat near a record boundary.
|
||
"""
|
||
if p + 2 > len(body):
|
||
return None, None
|
||
t0, t1 = body[p], body[p + 1]
|
||
hi = t0 & 0xF0
|
||
nn = ((t0 & 0x0F) << 8) | t1
|
||
if hi == 0x40: # int16 BE data block
|
||
# NN was capped at 0x08 until 2026-09-11. That cap had no basis: the
|
||
# two corpora available at the time only ever used NN in {1,2,3,4,8},
|
||
# so it was never exercised. Loud UM12947 events use NN of 12, 16,
|
||
# 20 ... up to 196, and every value above 8 halted the walk, which
|
||
# surfaced as silently short channels (walk_body/run stop at the first
|
||
# unrecognised tag rather than raising). Verified against Thor's own
|
||
# exports: 22 length-mismatched files -> 0, and the affected corpus
|
||
# went to 1,476,242/1,476,249 samples exact. The real bound is the
|
||
# buffer; the caller additionally clamps to the record end.
|
||
if nn == 0 or p + 2 * nn + 2 > len(body):
|
||
return None, None
|
||
return 2 * nn + 2, nn
|
||
if nn == 0 or nn % 4:
|
||
return None, None
|
||
if hi == 0x00:
|
||
return 2, nn # RLE hold
|
||
if hi == 0x10:
|
||
return nn // 2 + 2, nn # 4-bit nibble
|
||
if hi == 0x20:
|
||
return nn + 2, nn # int8
|
||
if hi == 0x30:
|
||
return nn * 3 // 2 + 2, nn # 12-bit packed
|
||
return None, None
|
||
|
||
|
||
def unpack16(data: bytes) -> List[int]:
|
||
"""Raw int16 BE absolute samples (MODE_RAW16)."""
|
||
return [_i16(data, 2 * k) for k in range(len(data) // 2)]
|
||
|
||
|
||
def unpack12(data: bytes) -> List[int]:
|
||
"""Raw 12-bit packed samples: 6 bytes -> 4 signed values."""
|
||
out: List[int] = []
|
||
for g in range(len(data) // 6):
|
||
hi = (data[6 * g] << 8) | data[6 * g + 1]
|
||
for k in range(4):
|
||
x = (((hi >> (12 - 4 * k)) & 0xF) << 8) | data[6 * g + 2 + k]
|
||
out.append(x - 0x1000 if x >= 0x800 else x)
|
||
return out
|
||
|
||
|
||
def is_record(body: bytes, p: int) -> bool:
|
||
"""True if a per-channel record header starts at *p*."""
|
||
return (p + 10 <= len(body)
|
||
and body[p + 4] in CHANNEL_IDS
|
||
and body[p + 5] == 0x00 and body[p + 6] == 0x00
|
||
and 8 <= _u16(body, p + 2) <= len(body) - p
|
||
and (body[p + 8], body[p + 9]) in _MODES)
|
||
|
||
|
||
def find_first_record(body: bytes) -> Optional[int]:
|
||
"""Offset of the first record, or None.
|
||
|
||
Under the normal ``00 02 00`` preamble the leading bytes are segment-0's
|
||
Tran blocks, so walk them. Under the untagged preambles (``00 00 03``
|
||
raw-12 and ``00 00 00`` raw-16) that data has no tags at all and cannot
|
||
be block-walked — scan for the next record header instead.
|
||
"""
|
||
if len(body) >= 3 and (body[1], body[2]) in _UNTAGGED_MODES:
|
||
scan_from = 3
|
||
else:
|
||
# Tagged preamble. MODE_DELTA carries a 14-byte record header (two
|
||
# int16 anchors), so its blocks start at body[7]; MODE_ABSOLUTE has a
|
||
# 10-byte header and starts at body[3].
|
||
i = 3 if (len(body) >= 3 and (body[1], body[2]) == MODE_ABSOLUTE) else 7
|
||
while i < len(body):
|
||
if is_record(body, i):
|
||
nxt = i + 2 + _u16(body, i + 2)
|
||
if nxt + 5 <= len(body) and (is_record(body, nxt)
|
||
or body[nxt + 4] == STREAM_END_ID):
|
||
return i
|
||
length, _ = data_block_len(body, i)
|
||
if length is None:
|
||
return None
|
||
i += length
|
||
return None
|
||
for i in range(scan_from, max(scan_from, len(body) - 10)):
|
||
if is_record(body, i):
|
||
nxt = i + 2 + _u16(body, i + 2)
|
||
if nxt + 5 <= len(body) and (is_record(body, nxt)
|
||
or body[nxt + 4] == STREAM_END_ID):
|
||
return i
|
||
return None
|
||
|
||
|
||
def walk_records(body: bytes, first: Optional[int] = None) -> List[dict]:
|
||
"""Follow the length chain from *first* to the ``0x06`` terminator."""
|
||
if first is None:
|
||
first = find_first_record(body)
|
||
out: List[dict] = []
|
||
if first is None:
|
||
return out
|
||
p, seen = first, set()
|
||
while p is not None and p + 10 <= len(body):
|
||
if p in seen:
|
||
break
|
||
seen.add(p)
|
||
cid = body[p + 4]
|
||
if cid == STREAM_END_ID or cid not in CHANNEL_IDS:
|
||
break
|
||
length = _u16(body, p + 2)
|
||
if length < 8 or p + 2 + length > len(body):
|
||
break
|
||
out.append({"offset": p, "channel": CHANNEL_IDS[cid],
|
||
"segment_index": body[p + 7],
|
||
"mode": (body[p + 8], body[p + 9]),
|
||
"end": p + 2 + length})
|
||
p += 2 + length
|
||
return out
|
||
|
||
|
||
def decode_waveform_v2(body: bytes) -> Optional[dict]:
|
||
"""Decode a Blastware waveform body into per-channel sample arrays.
|
||
|
||
Returns ``{"Tran": [...], "Vert": [...], "Long": [...], "MicL": [...]}``
|
||
in 16-count units (LSB = 0.005 in/s at Normal range), or None if *body*
|
||
is not a decodable waveform body.
|
||
|
||
Implements the record-chain model documented above.
|
||
"""
|
||
if len(body) < 8 or body[0] != 0x00:
|
||
return None
|
||
preamble = (body[1], body[2])
|
||
if preamble not in (MODE_DELTA, MODE_ABSOLUTE, MODE_RAW12, MODE_RAW16):
|
||
return None
|
||
first = find_first_record(body)
|
||
if first is None:
|
||
return None
|
||
|
||
out: dict = {c: [] for c in ("Tran", "Vert", "Long", "MicL")}
|
||
|
||
def run(channel: str, start: int, end: int, absolute: bool) -> None:
|
||
cur = out[channel][-1] if out[channel] else 0
|
||
i = start
|
||
while i < end:
|
||
length, nn = data_block_len(body, i)
|
||
if length is None or i + length > end:
|
||
return # stop this record; the chain resyncs at end
|
||
hi = body[i] & 0xF0
|
||
if hi == 0x00:
|
||
vals = [None] * nn
|
||
elif hi == 0x10:
|
||
vals = []
|
||
for k in range(nn):
|
||
byte = body[i + 2 + k // 2]
|
||
v = (byte >> 4) if k % 2 == 0 else (byte & 0xF)
|
||
vals.append(v - 16 if v >= 8 else v)
|
||
elif hi == 0x20:
|
||
vals = [v - 256 if v >= 128 else v
|
||
for v in body[i + 2:i + 2 + nn]]
|
||
elif hi == 0x30:
|
||
vals = unpack12(body[i + 2:i + length])
|
||
else:
|
||
vals = [_i16(body, i + 2 + 2 * k) for k in range(nn)]
|
||
for v in vals:
|
||
if v is None:
|
||
pass # RLE hold, in delta AND absolute modes
|
||
elif absolute:
|
||
cur = v
|
||
else:
|
||
cur += v
|
||
out[channel].append(cur)
|
||
i += length
|
||
|
||
# Segment 0 is an implicit Tran record carried in the preamble.
|
||
if preamble == MODE_DELTA:
|
||
out["Tran"].extend([_i16(body, 3), _i16(body, 5)])
|
||
run("Tran", 7, first, absolute=False)
|
||
elif preamble == MODE_ABSOLUTE:
|
||
run("Tran", 3, first, absolute=True)
|
||
elif preamble == MODE_RAW16:
|
||
out["Tran"].extend(unpack16(body[3:first]))
|
||
else:
|
||
out["Tran"].extend(unpack12(body[3:first]))
|
||
|
||
for rec in walk_records(body, first):
|
||
ch, off, mode, end = (rec["channel"], rec["offset"],
|
||
rec["mode"], rec["end"])
|
||
if mode == MODE_DELTA:
|
||
out[ch].extend([_i16(body, off + 10), _i16(body, off + 12)])
|
||
run(ch, off + 14, end, absolute=False)
|
||
elif mode == MODE_ABSOLUTE:
|
||
run(ch, off + 10, end, absolute=True)
|
||
elif mode == MODE_RAW12:
|
||
out[ch].extend(unpack12(body[off + 10:end]))
|
||
elif mode == MODE_RAW16:
|
||
out[ch].extend(unpack16(body[off + 10:end]))
|
||
return out
|