codec: wire decode_waveform_v2 into production; add MicL dB helper

Replaces the broken legacy int16 LE decoder in client.py with the
verified multi-channel codec.  Three changes:

1. blastware_file.extract_body_bytes(a5_frames) — new helper that
   factors out the body-reconstruction logic from write_blastware_file
   so both writers (BW binary) and decoders (sample arrays) can use
   the same canonical bytes.

2. waveform_codec.decode_a5_frames(a5_frames) — production entry point.
   Returns the raw_samples dict consumers expect (Tran/Vert/Long as
   int16 ADC counts; MicL as native ADC counts).  Internally:
     A5 frames → extract_body_bytes → decode_waveform_v2
                → decoded_to_adc_counts (geos ×16; mic pass-through)

3. waveform_codec.mic_count_to_db(count) — MicL ADC → dB(L) per BW's
   display formula:
     dB = sign(count) × (81.94 + 20 × log10(|count|))   for |count| ≥ 1
   Verified against V70 fixture: count=813 → 140.14 dB (BW PSPL 140.1).

client.py:_decode_a5_waveform is reduced to a thin wrapper that calls
decode_a5_frames and populates event.raw_samples.  Original implementation
preserved as _decode_a5_waveform_LEGACY (dead code; reference only).

Also fixed a tail-end bug in decode_waveform_v2 where trailer-section
"40 02" markers (containing ASCII serial bytes, NOT real segment headers)
were being mis-interpreted, producing 2 spurious samples per channel at
the end of each event.  Added bytes [12:14] == "02 00" validation to
reject non-header markers.

7 new pytest tests cover the new helpers and dB conversion.  Total:
71 passing (up from 64).

Known limitation (carried over from before): the walker still stops
mid-event on the loudest fixtures (SP0/SS0/SV0/event-b) at some
mid-segment edge cases not yet characterized.  Every sample reached
is decoded correctly; the walker just doesn't reach all of them.
Loud events still yield 5,000–15,000 byte-exact samples each.
This commit is contained in:
Claude
2026-05-16 00:27:14 +00:00
committed by serversdown
parent 2ff2762eec
commit 85f4bcfe86
6 changed files with 370 additions and 46 deletions
+59
View File
@@ -16,7 +16,9 @@ from minimateplus.waveform_codec import (
WaveformBlock,
decode_tran_initial,
decode_waveform_v2,
decoded_to_adc_counts,
find_data_start,
mic_count_to_db,
parse_segment_header,
split_segments,
walk_body,
@@ -448,3 +450,60 @@ def test_decode_tran_initial_full_segment_silent_events():
)
# And we should have decoded at least 400 samples (= segment 0 worth).
assert n >= 400, f"only {n} samples decoded for {path}"
# ── ADC scaling + dB conversion ──────────────────────────────────────────────
def test_decoded_to_adc_counts_geo_scales_by_16():
"""Geo channels in decoder units (16-count) should multiply by 16 to ADC."""
decoded = {"Tran": [0, 1, -2, 100], "Vert": [5], "Long": [-10], "MicL": [813]}
adc = decoded_to_adc_counts(decoded)
assert adc["Tran"] == [0, 16, -32, 1600]
assert adc["Vert"] == [80]
assert adc["Long"] == [-160]
# Mic passes through unchanged (already ADC counts).
assert adc["MicL"] == [813]
def test_decoded_to_adc_counts_empty():
assert decoded_to_adc_counts({}) == {}
assert decoded_to_adc_counts(
{"Tran": [], "Vert": [], "Long": [], "MicL": []}
) == {"Tran": [], "Vert": [], "Long": [], "MicL": []}
def test_mic_count_to_db_zero_is_zero():
assert mic_count_to_db(0) == 0.0
def test_mic_count_to_db_unit_is_reference():
"""count = ±1 → ±81.94 dB (the calibration reference)."""
assert abs(mic_count_to_db(1) - 81.94) < 0.01
assert abs(mic_count_to_db(-1) - (-81.94)) < 0.01
def test_mic_count_to_db_doubles_every_6db():
"""Each doubling of |count| adds ~6.02 dB."""
# count=2 → 87.96 dB (+ 6.02 from 81.94)
assert abs(mic_count_to_db(2) - 87.96) < 0.05
# count=4 → 93.98 dB
assert abs(mic_count_to_db(4) - 93.98) < 0.05
# count=8 → 100.00 dB
assert abs(mic_count_to_db(8) - 100.00) < 0.05
def test_mic_count_to_db_v70_peak():
"""V70 mic peak count 813 → 140.14 dB (matches BW reported PSPL 140.1)."""
assert abs(mic_count_to_db(813) - 140.14) < 0.1
# And the negative-direction equivalent
assert abs(mic_count_to_db(-813) - (-140.14)) < 0.1
# ── End-to-end: decode_a5_frames (production entry point) ───────────────────
def test_decode_a5_frames_empty():
from minimateplus.waveform_codec import decode_a5_frames
assert decode_a5_frames([]) is None
assert decode_a5_frames(None) is None