# 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: ``` RIFFWAVE fmt <16 (uint32 LE)> data ... 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(+ 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 ```python 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. ### 4.2 Kinds emitted by mlink_fake_ota today **`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 [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 `kind`s. 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).