fix: waveform decode improved for accuracy.

feat: adds 5a diagnostic script to parse raw binary
This commit is contained in:
2026-04-15 16:36:41 -04:00
parent 8bfebadd46
commit a46961c124
5 changed files with 1474 additions and 1100 deletions
+68
View File
@@ -266,6 +266,73 @@ silence). Only the initial variable-size chunks contain actual signal.
Removed. Terminator detection is via `page_key == 0x0000` in `read_bulk_waveform_stream`, Removed. Terminator detection is via `page_key == 0x0000` in `read_bulk_waveform_stream`,
not frame index. not frame index.
### SUB 5A — re-probe at counter=0x1000 (DLE collision, FIXED 2026-04-15)
**Root cause (confirmed from diagnostic output, desk-thump event key=01110000):**
`bulk_waveform_params()` sets `p[3] = (counter >> 8) & 0xFF`. For chunk 4, counter =
`4 * 0x0400 = 0x1000`, so `p[3] = 0x10` — the DLE byte. Because `build_5a_frame` writes
params RAW (no DLE stuffing), the on-wire byte sequence contains a bare `0x10`. The
device DLE-decodes its own receive buffer: `10 00` (the p[3]/p[4] pair) is collapsed to
`00`, so the counter field reads 0 — a probe request. The device re-sends the initial
probe response (containing the STRT record and first waveform bytes).
**Effect:** In a 36-frame stream for key=01110000, fi=4 was a byte-for-byte duplicate of
fi=0 (same db=1101B, same w[0:32] bytes, STRT present at w[10]). The old code treated it
as a regular chunk, decoded the STRT bytes as int16 samples (producing T=21587="ST",
V=21586="RT"), and shifted the running byte alignment for all subsequent frames.
**Fix (`_decode_a5_waveform`):** Check for STRT in every frame, not just fi==0. Any
non-fi=0 frame containing STRT is a re-probe — log it and skip (do NOT add to
`all_chunks`). Regular chunk path is reached only when `w.find(b"STRT") < 0`.
**Note:** This DLE collision is key-specific. For key=01110000 (`key4[2:4]=0x0000`),
counter = `chunk_num * 0x0400`, so counter=0x1000 occurs at chunk 4 for every event with
this key. For other keys (e.g. key=0111245a, `key4[2:4]=0x245A`), the chunk counter
formula `key4[2:4] + n*0x0400` produces different values; the collision only occurs when
the high byte of any counter is 0x10.
### SUB 5A — metadata false-positive (FIXED 2026-04-15)
**Root cause (confirmed from diagnostic output):**
The old metadata-frame test was `b"Project:" in w` (single anchor). For the 36-frame
desk-thump stream, fi=15 had `b"Project:"` at w[93] inside live ADC data — a coincidental
4-byte pattern in the waveform. The frame (134 live sample-sets) was incorrectly skipped.
**Fix:** Require BOTH `b"Project:" in w` AND `b"Client:" in w` to classify a frame as
metadata. The real metadata frame (fi=6 in the desk-thump stream) contains both strings
as part of the compliance-setup ASCII block; random ADC data is statistically unlikely to
contain both 8-byte sequences.
**Updated check in `_decode_a5_waveform`:**
```python
elif b"Project:" in w and b"Client:" in w:
log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi)
continue
```
### SUB 5A — 0xFF tail frames beyond record window (confirmed 2026-04-15)
The device bulk-streams flash pages beyond the configured record window. Un-written flash
pages read as 0xFF. Decoded as int16 LE, `0xFF 0xFF = -1`, which maps to ~0 in/s after
the geo scale factor is applied — producing a visible flat-line at the end of the waveform.
**Confirmed from diagnostic output (desk-thump event, 1024 sps, record_time=3.0 s):**
- total_samples = 256 (pretrig) + 3072 (post) = 3328
- samples_decoded = 4417 (36 frames)
- Excess tail: 4417 3328 = 1089 samples (8.7 frames of 0xFF data)
- Flat-line onset: sample 1960, t=1664ms post-trigger (within the active signal window
— earlier than expected because fi=4 re-probe and fi=15 false-positive removed ~270
samples; once those bugs are fixed the real onset should be at or beyond total_samples)
**Fix (two parts):**
1. `_decode_a5_waveform` (Python): already returns `total_samples` alongside
`samples_decoded`; the field is populated from compliance config:
`total_samples = pretrig_samples + round(record_time * sample_rate)`.
2. `sfm_webapp.html` (`_buildWaveformCharts`): `display = Math.min(decoded, total_samples)`;
`times` array and per-channel `samples` are both sliced to `display` length before
plotting — `(channels[ch] || []).slice(0, display)`. Without this, the chart rendered
all `decoded` samples including the 0xFF tail.
### SUB 1E / 1F — event iteration null sentinel and token position (FIXED, do not re-introduce) ### SUB 1E / 1F — event iteration null sentinel and token position (FIXED, do not re-introduce)
**token_params bug (FIXED):** The token byte was at `params[6]` (wrong). Both 3-31-26 and **token_params bug (FIXED):** The token byte was at `params[6]` (wrong). Both 3-31-26 and
@@ -1021,3 +1088,4 @@ call-home.
- Modem manager — push RV50/RV55 configs via Sierra Wireless API - Modem manager — push RV50/RV55 configs via Sierra Wireless API
- RV55 DCD/DTR issue — newer RV55 firmware doesn't assert DCD by default; units don't - RV55 DCD/DTR issue — newer RV55 firmware doesn't assert DCD by default; units don't
resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred) resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred)
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
diagnose_5a_frames.py -- Frame-by-frame diagnostic for SUB 5A waveform streams.
Usage:
python diagnose_5a_frames.py [--host HOST] [--port PORT] [--event INDEX]
Connects to the device, downloads the waveform for the specified event (default 0 =
most recently stored), and prints detailed per-frame info for every A5 response frame:
fi=N | db=NNN B w=NNN B | "Project:" in db=[offsets] in w=[offsets] <-- METADATA if detected
w[0:32] = <hex>
w[-8:] = <hex>
[waveform bytes or ASCII snippet]
Then shows:
- total non-metadata frames, total waveform bytes, total sample-sets decoded
- compliance-config expected vs decoded counts
- sample values at the flat-line onset region (~1700-1820)
- first near-zero run location (|T| < 20 for 10+ consecutive samples)
Run with: python diagnose_5a_frames.py 2>&1 | tee /tmp/diag_output.txt
"""
from __future__ import annotations
import argparse
import logging
import struct
import sys
# -- Setup logging -------------------------------------------------------------
logging.basicConfig(
level=logging.WARNING, # suppress library noise; we print our own output
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
from minimateplus import MiniMateClient
from minimateplus.transport import TcpTransport
log = logging.getLogger("diagnose")
log.setLevel(logging.INFO)
def decode_int16_sets(wave: bytes, n: int = 8) -> list[tuple[int, int, int, int]]:
"""Decode up to n sample-sets from wave bytes as [T, V, L, M] int16 LE."""
sets = []
for i in range(min(n, len(wave) // 8)):
off = i * 8
t = struct.unpack_from("<h", wave, off)[0]
v = struct.unpack_from("<h", wave, off + 2)[0]
l = struct.unpack_from("<h", wave, off + 4)[0]
m = struct.unpack_from("<h", wave, off + 6)[0]
sets.append((t, v, l, m))
return sets
def find_all(data: bytes, needle: bytes) -> list[int]:
"""Return all offsets where needle appears in data."""
positions = []
start = 0
while True:
pos = data.find(needle, start)
if pos < 0:
break
positions.append(pos)
start = pos + 1
return positions
def sep(label: str = "") -> None:
width = 80
if label:
pad = max(0, (width - len(label) - 2) // 2)
print(f"\n{'-' * pad} {label} {'-' * max(0, width - pad - len(label) - 2)}")
else:
print("-" * width)
def diagnose(frames_data: list[bytes], compliance_config=None) -> None:
"""Analyse all A5 frames and print diagnostic info."""
sep("PER-FRAME ANALYSIS")
print(f"Total A5 frames received: {len(frames_data)}")
print()
all_chunks: list[tuple[int, bytes]] = [] # (fi, wave_bytes)
cumulative_wave_bytes = 0
for fi, db in enumerate(frames_data):
w = db[7:] # what _decode_a5_waveform sees (db[7:])
# Find "Project:" in both the full frame data and the w=db[7:] slice
proj_in_db = find_all(db, b"Project:")
proj_in_w = find_all(w, b"Project:")
# The live detector in client.py uses: b"Project:" in w
detected_as_metadata = bool(proj_in_w)
flag = " <-- METADATA (skipped)" if detected_as_metadata else ""
print(f"fi={fi:3d} db={len(db):5d}B w={len(w):5d}B "
f"Project: in db={proj_in_db} in w(db[7:])={proj_in_w}{flag}")
hex_head = w[:32].hex(' ')
hex_tail = w[-8:].hex(' ') if len(w) >= 8 else w.hex(' ')
print(f" w[0:32] = {hex_head}")
print(f" w[-8:] = {hex_tail}")
if fi == 0:
sp = w.find(b"STRT")
if sp >= 0:
strt = w[sp:sp + 21]
print(f" STRT at w[{sp}]: {strt.hex(' ')}")
wave = w[sp + 21:]
if wave:
sets = decode_int16_sets(wave, 4)
print(f" wave[sp+21:] first 4 sets (T,V,L,M): {sets}")
all_chunks.append((fi, wave))
cumulative_wave_bytes += len(wave)
print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8}")
else:
print(f" *** STRT NOT FOUND ***")
elif detected_as_metadata:
# Print the ASCII content to confirm this is the real metadata frame
try:
snippet = w.decode("ascii", errors="replace")
# Find the first 200 printable characters
printable = snippet[:200].replace("\x00", ".").replace("\r", "\n").replace("\n", "\n")
print(f" ASCII: {repr(printable[:140])}")
except Exception as e:
print(f" (decode error: {e})")
else:
# Regular chunk: strip 8-byte header
if len(w) >= 8:
wave = w[8:]
all_chunks.append((fi, wave))
cumulative_wave_bytes += len(wave)
sets = decode_int16_sets(wave, 4)
# Count near-zero Tran values
all_sets = decode_int16_sets(wave, len(wave) // 8)
nz = sum(1 for s in all_sets if abs(s[0]) < 20)
print(f" wave[8:] first 4 sets: {sets}")
print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8} "
f"near-zero(|T|<20): {nz}/{len(all_sets)}")
print()
# -- Waveform value analysis ------------------------------------------------
sep("WAVEFORM DECODE")
cc_sr = 1024
cc_rt = None
pretrig = 256
total_expected = 0
if compliance_config:
cc_sr = compliance_config.sample_rate or 1024
cc_rt = compliance_config.record_time
pretrig = int(round(0.25 * cc_sr))
if cc_rt:
total_expected = pretrig + int(round(cc_rt * cc_sr))
print(f"Compliance: sr={cc_sr} sps record_time={cc_rt} s "
f"pretrig={pretrig} total_expected={total_expected}")
else:
print("No compliance config -- using defaults: sr=1024, pretrig=256")
total_wave_bytes = sum(len(w) for _, w in all_chunks)
total_sets_raw = total_wave_bytes // 8
print(f"Non-metadata frames: {len(all_chunks)} "
f"Total wave bytes: {total_wave_bytes} "
f"Raw sample-sets: {total_sets_raw}")
# Alignment-corrected decode (matches _decode_a5_waveform exactly)
tran: list[int] = []
running_offset = 0
for fi, wave in all_chunks:
align = running_offset % 8
skip = (8 - align) % 8
if skip > 0 and skip < len(wave):
usable = wave[skip:]
elif align == 0:
usable = wave
else:
running_offset += len(wave)
continue
n_usable = len(usable) // 8
for i in range(n_usable):
tran.append(struct.unpack_from("<h", usable, i * 8)[0])
running_offset += len(wave)
n_decoded = len(tran)
print(f"Alignment-corrected decoded Tran samples: {n_decoded}")
if compliance_config and cc_rt:
print(f"Expected: {total_expected} Decoded: {n_decoded} "
f"Excess (tail): {max(0, n_decoded - total_expected)}")
print()
print(f"First 16 Tran: {tran[:16]}")
if n_decoded >= 32:
print(f"Last 16 Tran: {tran[-16:]}")
# -- Flat-line onset search -------------------------------------------------
sep("FLAT-LINE ONSET (first run of 10+ consecutive |Tran| < 20)")
run_start = None
run_len = 0
onset_found = False
for i, v in enumerate(tran):
if abs(v) < 20:
if run_start is None:
run_start = i
run_len += 1
else:
if run_len >= 10:
t_ms = (run_start - pretrig) * 1000.0 / cc_sr
print(f" First near-zero run: sample {run_start}-{run_start + run_len - 1} "
f"(t={t_ms:.1f}ms post-trigger) length={run_len}")
onset_found = True
break
run_start = None
run_len = 0
else:
if run_len >= 10 and run_start is not None:
t_ms = (run_start - pretrig) * 1000.0 / cc_sr
print(f" Near-zero run at end: sample {run_start}-{n_decoded - 1} "
f"(t={t_ms:.1f}ms post-trigger) length={run_len}")
onset_found = True
if not onset_found:
print(" No near-zero run of 10+ samples found (waveform looks active throughout)")
# Print samples around the expected flat-line onset (~1700-1820)
if n_decoded >= 1700:
print()
print("Tran samples [1700:1820] (10 per line):")
for row_start in range(1700, min(1820, n_decoded), 10):
row = tran[row_start:row_start + 10]
t_ms_row = (row_start - pretrig) * 1000.0 / cc_sr
print(f" [{row_start:4d}] (t={t_ms_row:6.1f}ms): {row}")
else:
print(f" Only {n_decoded} samples decoded -- range 1700-1820 not available")
def main() -> None:
parser = argparse.ArgumentParser(description="Diagnose A5 5A waveform frames")
parser.add_argument("--host", default="63.43.212.232", help="Device IP")
parser.add_argument("--port", type=int, default=9034, help="TCP port")
parser.add_argument("--event", type=int, default=0, help="Event index (0=first stored)")
args = parser.parse_args()
print(f"Connecting to {args.host}:{args.port} ...")
print(f"Target event index: {args.event}")
print()
transport = TcpTransport(args.host, port=args.port)
with MiniMateClient(transport=transport) as client:
info = client.connect()
print(f"Device: serial={info.serial} firmware={info.firmware_version}")
compliance_config = info.compliance_config
if compliance_config:
print(f"Compliance: sample_rate={compliance_config.sample_rate} "
f"record_time={compliance_config.record_time}")
print()
proto = client._proto
assert proto is not None
# -- Walk to the target event ------------------------------------------
log.info("Reading first event key (SUB 1E) ...")
first_key4, first_data8 = proto.read_event_first(token=0)
print(f"First event key: {first_key4.hex()}")
cur_key4 = first_key4
cur_data8 = first_data8
event_idx = 0
while event_idx < args.event:
# 0A required before each 1F to establish device context
proto.read_waveform_header(cur_key4)
next_key4, next_data8 = proto.advance_event(browse=True)
if next_data8[4:8] == b"\x00\x00\x00\x00":
print(f"Only {event_idx + 1} events available; cannot reach index {args.event}")
return
cur_key4 = next_key4
cur_data8 = next_data8
event_idx += 1
print(f" advanced to event {event_idx}: key={cur_key4.hex()}")
print(f"\nDownloading event {args.event}: key={cur_key4.hex()}")
# -- Full download sequence (matches get_events download-mode) ---------
log.info("0A: read_waveform_header ...")
proto.read_waveform_header(cur_key4)
log.info("1E(0xFE): arm device for 5A ...")
proto.read_event_first(token=0xFE)
log.info("0C: read_waveform_record ...")
wfm_raw = proto.read_waveform_record(cur_key4)
print(f"0C waveform record: {len(wfm_raw)} bytes")
log.info("1F(0xFE): arm 5A state machine ...")
arm_key4, _ = proto.advance_event(browse=False)
print(f"1F(arm) returned key: {arm_key4.hex()}")
log.info("POLLx3 ...")
for i in range(3):
proto.poll()
print(f" POLL {i+1}/3 OK")
print(f"\nStarting 5A bulk stream for key={cur_key4.hex()} ...")
frames_data = proto.read_bulk_waveform_stream(
cur_key4,
stop_after_metadata=False,
max_chunks=2048,
)
print(f"5A complete: {len(frames_data)} A5 frames")
print()
# -- Run the diagnostic ------------------------------------------------
diagnose(frames_data, compliance_config)
if __name__ == "__main__":
main()
+43 -38
View File
@@ -1440,29 +1440,50 @@ def _decode_a5_waveform(
for fi, db in enumerate(frames_data): for fi, db in enumerate(frames_data):
w = db[7:] w = db[7:]
# A5[0]: waveform begins immediately after the 21-byte STRT record. # ── Probe frames (fi==0 AND any re-probe the device sends mid-stream) ────
# Confirmed 2026-04-14: there is NO preamble after STRT — bytes 21+ # A5[0] always contains the STRT record. For event key 0x01110000,
# are raw ADC sample data. The earlier sp+27 skip was eating 6 bytes # chunk 4 (counter=0x1000) has 0x10 in the counter high byte; the device
# of real waveform, misaligning the channel decode for all subsequent # DLE-decodes the params and sees counter=0x0000 (probe), so it responds
# frames. # with a duplicate probe frame containing the same STRT. The diagnostic
if fi == 0: # from 2026-04-15 confirmed this: fi=4 was byte-for-byte identical to fi=0
sp = w.find(b"STRT") # (same db length 1101B, same STRT at w[10], same first 32 bytes).
if sp < 0: #
# Handling: any frame — not just fi==0 — that contains the STRT magic is
# treated as a probe frame. Waveform starts at strt_pos + 21 (no preamble).
# Re-probe frames are complete duplicates of fi=0 (device re-sends the
# beginning of the event), so their post-STRT waveform bytes are DROPPED
# to avoid injecting duplicate data into the stream.
sp = w.find(b"STRT")
if sp >= 0:
if fi == 0:
wave = w[sp + 21 :]
log.info(
"_decode_a5_waveform: A5[0] probe — STRT at w[%d], "
"waveform starts at sp+21; first 24 wave bytes: %s",
sp, wave[:24].hex(' '),
)
else:
# Re-probe frame: device re-sent probe in response to a chunk
# request whose counter byte happened to be 0x10 (DLE).
# The post-STRT bytes are a duplicate of the initial waveform
# — drop this frame entirely to avoid double-counting data.
log.info(
"_decode_a5_waveform: fi=%d re-probe (STRT at w[%d]) — "
"skipped (duplicate probe response from device)",
fi, sp,
)
continue continue
wave = w[sp + 21 :]
log.info(
"_decode_a5_waveform: A5[0] waveform starts at sp+21; "
"first 24 wave bytes: %s",
wave[:24].hex(' '),
)
# Metadata frame: contains "Project:", "Client:", etc. strings. # Metadata frame: contains BOTH "Project:" and "Client:" strings.
# Originally assumed to be always fi==7 (A5[7] in 4-2-26 blast capture), # Requiring two compliance anchors prevents false positives where ADC
# but confirmed variable position — it appears at whatever chunk index the # bytes accidentally spell "Project:" (confirmed false positive at fi=15
# device places it (observed at fi=6 for desk-thump events 2026-04-14). # in the 2026-04-15 desk-thump download — only "Project:" appeared there,
# Skip ANY frame whose raw bytes contain b"Project:" — this is the same # not "Client:"). The real metadata frame always contains both.
# anchor used by stop_after_metadata in read_bulk_waveform_stream. # This is the same anchor used by stop_after_metadata in
elif b"Project:" in w: # read_bulk_waveform_stream (which only checks "Project:" — see note
# there about the asymmetry: stopping early is fine with one anchor,
# but skipping a waveform frame requires higher confidence).
elif b"Project:" in w and b"Client:" in w:
log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi) log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi)
continue continue
@@ -2248,20 +2269,4 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus:
# Payload length varies (4649 bytes) but the battery/memory block is always # Payload length varies (4649 bytes) but the battery/memory block is always
# the last 10 bytes. No checksum byte — it was stripped by S3FrameParser. # the last 10 bytes. No checksum byte — it was stripped by S3FrameParser.
# #
# section[-10:-8] battery × 100 uint16 BE 0x02A8 = 6.80 V # section[-1
# section[-8 :-4] memory_total uint32 BE ≈ 960 KB on BE11529
# section[-4:] memory_free uint32 BE decreases as events fill
#
# Confirmed stable across IDLE (46b), MONITORING (48-49b) variants.
if len(section) >= 10:
batt_raw = struct.unpack(">H", section[-10:-8])[0]
battery_v = batt_raw / 100.0
memory_total = struct.unpack(">I", section[-8:-4])[0]
memory_free = struct.unpack(">I", section[-4:])[0]
return MonitorStatus(
is_monitoring=is_monitoring,
battery_v=battery_v,
memory_total=memory_total,
memory_free=memory_free,
)
+12 -39
View File
@@ -1555,6 +1555,14 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
const sr = data.sample_rate || 1024; const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0; const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0; const decoded = data.samples_decoded || 0;
// Clip display to total_samples (pretrig + post_trig from compliance config).
// The device bulk-streams zero-padded (0xFF = -1) frames beyond the configured
// record window; without clipping these appear as a flat line at ~0 in/s past
// the end of the actual recording. Confirmed 2026-04-15: a 36-frame 5A stream
// for a 3.25s event (total_samples=3328) contained 19 trailing all-0xFF frames
// (2457 extra samples) that caused a visible flat-line in the waveform display.
const total = (data.total_samples && data.total_samples > 0) ? data.total_samples : decoded;
const display = Math.min(decoded, total);
const channels = data.channels || {}; const channels = data.channels || {};
// Destroy old chart instances // Destroy old chart instances
@@ -1574,7 +1582,7 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
return; return;
} }
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2)); const times = Array.from({length: display}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
if (emptyEl) emptyEl.style.display = 'none'; if (emptyEl) emptyEl.style.display = 'none';
chartsEl.style.display = 'flex'; chartsEl.style.display = 'flex';
chartsEl.style.flexDirection = 'column'; chartsEl.style.flexDirection = 'column';
@@ -1584,8 +1592,8 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
const micPeakPsi = data.peak_values?.micl_psi ?? null; const micPeakPsi = data.peak_values?.micl_psi ?? null;
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) { for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch]; const samples = (channels[ch] || []).slice(0, display);
if (!samples || samples.length === 0) continue; if (samples.length === 0) continue;
const isGeo = ch !== 'Mic'; const isGeo = ch !== 'Mic';
let plotData, peakLabel, yUnit, ttFmt, tickFmt; let plotData, peakLabel, yUnit, ttFmt, tickFmt;
@@ -2118,39 +2126,4 @@ document.getElementById('api-base').value = window.location.origin;
</div> </div>
<button onclick="closeWfModal()" <button onclick="closeWfModal()"
style="background:none; border:none; color:var(--text-dim); cursor:pointer; style="background:none; border:none; color:var(--text-dim); cursor:pointer;
font-size:20px; line-height:1; padding:0 2px; flex-shrink:0;" font-si
title="Close (Esc)">×</button>
</div>
<!-- Peaks bar — reuses .peaks-bar styles from live Events tab -->
<div class="peaks-bar" id="wf-modal-peaks">
<div class="pk"><div class="pk-label">Tran</div><div class="pk-value pk-tran" id="wf-mpk-tran"></div></div>
<div class="pk"><div class="pk-label">Vert</div><div class="pk-value pk-vert" id="wf-mpk-vert"></div></div>
<div class="pk"><div class="pk-label">Long</div><div class="pk-value pk-long" id="wf-mpk-long"></div></div>
<div class="pk"><div class="pk-label">MicL</div><div class="pk-value pk-mic" id="wf-mpk-mic"></div></div>
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="wf-mpk-pvs"></div></div>
</div>
<!-- Debug panel (same as live debug panel, hidden by default) -->
<div id="wf-modal-debug"
style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('wf-modal-debug').style.display='none'">hide</span>
<div id="wf-modal-debug-content"></div>
</div>
<!-- Waveform area -->
<div style="flex:1; overflow-y:auto; min-height:200px;">
<div id="wf-modal-empty"
style="display:flex; flex-direction:column; align-items:center;
justify-content:center; padding:60px 20px; color:var(--text-dim); gap:12px;">
<p>Loading…</p>
</div>
<div id="wf-modal-charts" style="display:none;"></div>
</div>
</div>
</div>
</body>
</html>
Binary file not shown.