Files
rspektrum/mlnl_chunk_spec.md
T

12 KiB

mLnL: mLink-annotated WAV chunk specification (v2)

A way to ship spectrograph annotations inside a regular WAV file so the file stays standards-compliant audio everywhere while a viewer that knows the format can render labelled regions (TX bursts, assertion outcomes, impairment fires, etc.) over the audio's spectrogram.

Status: v2, stable. Producer: mlink_fake_ota (see src/tools/harness/fota_capture.c + fota_events.c). Consumer: any tool that walks RIFF chunks and looks for the four-CC mLnL.


1. WAV / RIFF refresher

A standard WAV file is a RIFF container:

RIFF<file_size - 8 (uint32 LE)>WAVE
  fmt <16 (uint32 LE)><PCM format fields>
  data<N (uint32 LE)><PCM samples>
  ... more chunks may follow ...

Each chunk = 4-byte four-CC ID + 4-byte unsigned little-endian size + payload of exactly that many bytes + 1 pad byte if the size is odd. Per the RIFF specification, a reader MUST skip any chunk whose ID it does not recognise.

2. The mLnL chunk

mLnL<payload_size (uint32 LE)><JSONL bytes>(+ 1 pad if size odd)

ID: ASCII mLnL (= bytes 0x6D 0x4C 0x6E 0x4C).

Payload: UTF-8 JSON Lines. One JSON object per line, terminated by \n. Lines are emitted in chronological order of t_start.

A truncation marker may appear as the last line:

{"truncated":true}

This means the producer's in-memory buffer overflowed and some events were dropped.

3. Reading the chunk

def read_mlnl(path):
    with open(path, 'rb') as f:
        data = f.read()
    if data[:4] != b'RIFF' or data[8:12] != b'WAVE':
        return None
    i = 12
    while i + 8 <= len(data):
        cid = data[i:i+4]
        sz  = int.from_bytes(data[i+4:i+8], 'little')
        if cid == b'mLnL':
            return data[i+8 : i+8+sz].decode('utf-8').splitlines()
        i += 8 + sz + (sz & 1)
    return None

4. Event schema (v2)

Every event is a single JSON object with these REQUIRED fields:

field type meaning
t_start number start of the event's time range, seconds since channel start
t_end number end of the event's time range, seconds; == t_start for instantaneous
kind string event kind (see §4.2)

4.1 Optional fields available on any kind

field type meaning
f_lo number low edge of frequency annotation (Hz)
f_hi number high edge of frequency annotation (Hz)
f_center number center frequency (Hz); alternative to f_lo/f_hi
f_bw number bandwidth around f_center (Hz); paired with f_center
note string free-form human-readable label (the "why this is here" line)
color string suggested fill or outline colour, #RRGGBB

A consumer should accept EITHER f_lo+f_hi OR f_center+f_bw (or both -- prefer f_lo/f_hi if both are present). Events with no frequency fields apply to the whole spectrum.

tx_frame is the primary annotation — ONE per on-air air-frame, sourced from the transmitting daemon's own per-frame log event (its self-report, forwarded to the channel over the logging egress), NOT guessed from the mixed audio. A whole transmission renders as its real labelled sequence of boxes: e.g. PRESENCE + RTS in an announce channel, then N bulk OFDM frames in the bulk band, each tagged with its LDPC rate.

tx_frame          t_start = frame key-down, t_end = key-up, placed at the
                  ACTUAL on-air anchor + the frame's sample offset (NOT the
                  modem's render/intent time -- see note below)
                  fields:
                    node   u32   transmitting node_id (attribution + color)
                    seq    int   0-based index of this frame within the PTT
                    n      int   total frames in this PTT  -> "seq of n"
                    frame  str   mLink frame name (vocabulary below)
                    ch     str   daemon channel: EMERGENCY / ANNOUNCE_0|1|2 /
                                 BULK  (the region the frame occupies)
                    rate   str?  LDPC rate of a bulk OFDM frame
                                 ("1/2","2/3","3/4","5/6"); absent on announce
                    f_lo   num   low edge of `ch`'s band (Hz; see §4.4)
                    f_hi   num   high edge of `ch`'s band (Hz)
                    sched_offset_ms num  intent->air latency of this burst:
                                 (on-air rising edge) - (modem render time).
                                 A "modem wants vs actually does" metric;
                                 coarse (block-quantized, multi-stage).
                    color  str   #RRGGBB, stable per node
                    note   str   pre-formatted "node N <frame> [rate]"

control           t_start == t_end ; one channel control verb dispatched
                  fields: command, req_id?
assertion_passed  t_start = watch start, t_end = match observation
                  fields: name, slack_s, note, color (green default
                          #3CB371), f_lo?, f_hi?
assertion_failed  t_start = watch start, t_end = watch deadline
                  fields: name, reason, note, color (red default
                          #D62828), f_lo?, f_hi?

frame vocabulary (announce family/subtype + bulk; render verbatim): PRESENCE/AUTO, PRESENCE/OPERATOR, PRESENCE/GEOPOLL, PRESENCE/GEOPOLL_RESP, PRESENCE/SEED, PRESENCE/SEED_ACK, BULK/RTS, BULK/ACK, BULK/DNAK, BULK/SBSERIES, BULK/SBRTS, BULK/RETX_RTS, BULK/SB_RETX_RTS, BULK/INET_REQ, BULK (one OFDM data frame), SHORTBURST (one strung-announce data frame), RELAY (plus the relay handshake subtypes RELAY/REQUEST, RELAY/OFFER, RELAY/SELECT, RELAY/FOR, RELAY/RESULT, and the strung emergency rebroadcast RELAY/EMERG / Authoritative ACK RELAY/EMERG_ACK), APRS. New names appear as the protocol grows — treat frame as a free-form short string.

REMOVED — tx_burst (energy-detected): the channel's old guess at "a station keyed, roughly narrow or wide," from mixed-audio energy + an HF ratio + manual tag_next_burst labels. Replaced by tx_frame (the daemon knows exactly what it keyed) and no longer emitted; tag_next_burst is gone too. A pre-2026- 05-28 wav may still contain tx_burst; treat it as opaque/legacy.

On-air anchoring (why sched_offset_ms exists). The daemon emits its tx_frame log when it renders a transmission (its intent), but the audio only reaches the air after the keying/buffering pipeline. The producer buffers each node's frames as the logs arrive (intent time) and places them only once the node's actual on-air energy rising edge is observed (air time) -- so the boxes land on the real signal. sched_offset_ms = air_time - intent_time is that gap, reported per burst. It's a useful "modem wants vs actually does" metric but coarse (block-quantized; conflates render/queue/buffer/transit), so read it as an indicator, not a precise stopwatch.

channel_up / channel_down are not emitted: the wav itself bounds the session (first/last sample == up/down edge).

Future kinds reserved (not all emitted yet):

rx_event          a receiver's decode/deliver outcome
                  ({ node, from, snr_db, frame|kind, ... })
impairment_fire   { name, ... }

Consumers should ignore unknown kinds gracefully.

4.3 Field conventions

  • node is a 32-bit unsigned integer (the registered node_id).
  • Frequencies are in Hz, durations in seconds, amplitudes in channel-internal units roughly [-1, +1] before auto-gain.
  • command matches the verbs in tools/harness/fota_control.h.
  • name (for assertions) should ideally read as a hypothesis like IF SEED THEN B SEED_ACK WITHIN 4s -- the renderer uses it as the region label.

4.4 Channel -> spectral band

tx_frame carries both the daemon channel name (ch) and the resolved band (f_lo/f_hi). The mapping (mLink's finalized spectrum allocation):

ch band (Hz) mode / center
EMERGENCY 125 .. 175 NB8FSK, 150 Hz
ANNOUNCE_0 265 .. 535 QPSK, 400 Hz
ANNOUNCE_1 665 .. 935 QPSK, 800 Hz
ANNOUNCE_2 1065 .. 1335 QPSK, 1200 Hz
BULK 1594 .. 2906 OFDM, 2250 Hz center

The three announce channels are ~270 Hz wide and non-overlapping, so a PRESENCE/RTS box and the bulk blob of the same transmission land in clearly separate horizontal lanes. A renderer can trust f_lo/f_hi directly or re-derive from ch via this table.

5. Rendering tips for a spectrograph

  • Time axis: t_start and t_end map directly to the wav's time axis (sample N = t * sample_rate). Draw the annotation as a horizontal span over [t_start, t_end].
  • Frequency axis: if the event has f_lo/f_hi, render as a rectangle spanning [t_start, t_end] x [f_lo, f_hi]. If only f_center/f_bw, use [f_center - f_bw/2, f_center + f_bw/2]. If neither, span the full frequency axis.
  • For tx_frame (the main case): render a filled / translucent box over [t_start, t_end] x [f_lo, f_hi] in the frame's color; label it with frame (or the pre-formatted note). Because each frame carries its own exact band, consecutive frames of one transmission draw as a readable sequence -- a narrow PRESENCE then RTS in an announce lane, then the bulk frames stacked in the bulk lane. Use seq/n to show position ("3/6") and rate to annotate the LDPC ramp on bulk frames.
  • For assertion_passed: thin outlined rectangle in green (use color field if present, else #3CB371); label with note.
  • For assertion_failed: thin outlined rectangle in red (or color); label with note. This is the "we were expecting something here and it never arrived" overlay -- visually distinguishing it from passes is the point.
  • For control: vertical markers at t_start (zero-width).
  • Per-node colors are stable across the run (the producer cycles a built-in palette by node_id); consumers may honour them or override with their own per-node scheme.
  • Layer order: tx_frame (background fill) -> assertion outcomes (mid- layer outlines) -> control markers (top). Keep opacity moderate so overlapping annotations stay legible.
  • tx_burst is no longer emitted (removed). Only a legacy pre-2026-05-28 wav carries it; render tx_frame and ignore tx_burst if present.

6. Producer responsibilities

  1. Write fmt + data chunks first in standard form (file plays as plain audio for any tool).
  2. Append mLnL after data with the JSONL bytes.
  3. Patch the outer RIFF size at file offset 4 to file_size - 8.
  4. Pad to even byte boundary if needed (single \0 byte; not counted in the chunk size field).
  5. Emit JSONL in monotonic t_start order.
  6. Each line is a single JSON object terminated by \n -- no multi-line objects, no trailing commas.

7. Consumer responsibilities

  • Treat any unknown chunk ID as opaque -- skip it per RIFF rules.
  • Walk chunks from the start; don't assume mLnL is at a fixed offset.
  • Handle a {"truncated":true} last-line case as a status flag.
  • Tolerate unknown fields and unknown kind values.

8. Version + compatibility

The chunk ID mLnL corresponds to schema v2 (this document). v1 (now deprecated) used {t_s, kind, ...} with paired *_start/*_stop events; v2 collapses pairs into ranges with t_start/t_end and adds the frequency / note / color fields. Within v2: tx_frame (the daemon-sourced per-frame kind, with node/seq/n/frame/ch/rate/f_lo/f_hi/ sched_offset_ms/color/note) was added 2026-05-28 and is the primary annotation, anchored on the actual on-air edge; the energy-detected tx_burst and the tag_next_burst verb were removed in its favour. Consumers MUST tolerate both presence and absence of all optional fields and unknown kinds.

If we need an incompatible schema change later, we'll mint a new four-CC (mLn3 etc.) and keep mLnL reserved for v2.

9. References

  • mLink producer source: src/tools/harness/fota_capture.c (append_riff_chunk + fota_capture_write_wav_with_annotations)
  • Event emitter: src/tools/harness/fota_events.{c,h}
  • tx_frame ingest (forwarded-log -> annotation): the FOTA_MSG_LOG handler
    • ingest_log_event in src/tools/harness/mlink_fake_ota.c; the daemon side emits tx_frame in src/protocol/tx_render.c.
  • Assertion engine: src/tools/harness/fota_assert.{c,h}
  • RIFF specification: Microsoft Multimedia Programming Interface and Data Specifications 1.0 (1991).