diff --git a/build_web.sh b/build_web.sh index 3b6fb14..a7271db 100755 --- a/build_web.sh +++ b/build_web.sh @@ -55,7 +55,7 @@ fi echo "=== Step 2: Compiling spectrogram modules for web ===" cd "$SCRIPT_DIR" -APP_MODULES="spectrogram fft stft audio render ui utils primitives platform_web" +APP_MODULES="spectrogram fft stft audio render ui utils primitives mlnl platform_web" APP_OPT="-Os" if [ "$BUILD_TYPE" = "debug" ]; then diff --git a/mlnl_chunk_spec.md b/mlnl_chunk_spec.md new file mode 100644 index 0000000..f5d938b --- /dev/null +++ b/mlnl_chunk_spec.md @@ -0,0 +1,276 @@ +# 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`, `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). diff --git a/mlnl_samples.wav b/mlnl_samples.wav new file mode 100644 index 0000000..ac5a6fd Binary files /dev/null and b/mlnl_samples.wav differ diff --git a/rspektrum.make b/rspektrum.make index a3491e2..7c9b33e 100644 --- a/rspektrum.make +++ b/rspektrum.make @@ -127,6 +127,7 @@ GENERATED += $(OBJDIR)/ui.o GENERATED += $(OBJDIR)/platform_linux.o GENERATED += $(OBJDIR)/utils.o GENERATED += $(OBJDIR)/primitives.o +GENERATED += $(OBJDIR)/mlnl.o OBJECTS += $(OBJDIR)/spectrogram.o OBJECTS += $(OBJDIR)/fft.o OBJECTS += $(OBJDIR)/stft.o @@ -136,6 +137,7 @@ OBJECTS += $(OBJDIR)/ui.o OBJECTS += $(OBJDIR)/platform_linux.o OBJECTS += $(OBJDIR)/utils.o OBJECTS += $(OBJDIR)/primitives.o +OBJECTS += $(OBJDIR)/mlnl.o # Rules # ############################################# @@ -235,6 +237,10 @@ $(OBJDIR)/primitives.o: src/primitives.c @echo "$(notdir $<)" $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" +$(OBJDIR)/mlnl.o: src/mlnl.c + @echo "$(notdir $<)" + $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" + -include $(OBJECTS:%.o=%.d) ifneq (,$(PCH)) -include $(PCH_PLACEHOLDER).d diff --git a/shot_input.sh b/shot_input.sh index 8808ec2..96d76ac 100755 --- a/shot_input.sh +++ b/shot_input.sh @@ -25,7 +25,7 @@ # a selection box needs a >5 px drag or it reverts to "no selection". set -u OUT="${1:-/tmp/rspek_shot.png}"; shift || true -REPO=/home/tyler/ham/Audio-Experiments/rspektrum +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DISP=:99 WAV="${RSPEK_WAV:-/tmp/mlink_bulk_drop.wav}" diff --git a/src/mlnl.c b/src/mlnl.c new file mode 100644 index 0000000..f76e158 --- /dev/null +++ b/src/mlnl.c @@ -0,0 +1,387 @@ +// mlnl.c - parse the optional `mLnL` RIFF chunk from a WAV file (schema v2). +// Payload is UTF-8 JSON Lines, one flat object per line. +#include "mlnl.h" + +#include +#include +#include +#include + +// ============================================================================ +// RIFF walker — find the `mLnL` chunk payload. +// ============================================================================ + +static unsigned int ReadU32LE(const unsigned char* p) +{ + return (unsigned int)p[0] | ((unsigned int)p[1] << 8) | + ((unsigned int)p[2] << 16) | ((unsigned int)p[3] << 24); +} + +// Returns a malloc'd payload buffer (NUL-terminated for splitline convenience) +// and sets *outLen. NULL if the file isn't WAV or has no mLnL chunk. +static char* FindMlnlChunk(const char* path, size_t* outLen) +{ + FILE* f = fopen(path, "rb"); + if (!f) return NULL; + + unsigned char hdr[12]; + if (fread(hdr, 1, 12, f) != 12) { fclose(f); return NULL; } + if (memcmp(hdr, "RIFF", 4) != 0 || memcmp(hdr + 8, "WAVE", 4) != 0) { + fclose(f); return NULL; + } + + for (;;) { + unsigned char ch[8]; + size_t got = fread(ch, 1, 8, f); + if (got != 8) break; + unsigned int sz = ReadU32LE(ch + 4); + if (memcmp(ch, "mLnL", 4) == 0) { + // Cap at 64 MiB to avoid a corrupt size eating memory. + if (sz > 64u * 1024u * 1024u) { fclose(f); return NULL; } + char* buf = (char*)malloc((size_t)sz + 1); + if (!buf) { fclose(f); return NULL; } + if (fread(buf, 1, sz, f) != sz) { free(buf); fclose(f); return NULL; } + buf[sz] = '\0'; + *outLen = sz; + fclose(f); + return buf; + } + // Skip the payload (+ 1 pad byte if size is odd). + long skip = (long)sz + (long)(sz & 1u); + if (fseek(f, skip, SEEK_CUR) != 0) break; + } + fclose(f); + return NULL; +} + +// ============================================================================ +// Tiny JSON-object-per-line parser. Flat objects with scalar values. +// Nested objects/arrays in unknown fields are tolerated by being skipped. +// ============================================================================ + +typedef struct { + const char* s; + int pos; + int len; +} Scanner; + +static void SkipWs(Scanner* sc) +{ + while (sc->pos < sc->len && (sc->s[sc->pos] == ' ' || sc->s[sc->pos] == '\t')) sc->pos++; +} + +static bool ReadString(Scanner* sc, char* out, int outCap) +{ + if (sc->pos >= sc->len || sc->s[sc->pos] != '"') return false; + sc->pos++; + int w = 0; + while (sc->pos < sc->len && sc->s[sc->pos] != '"') { + char c = sc->s[sc->pos++]; + if (c == '\\' && sc->pos < sc->len) { + char e = sc->s[sc->pos++]; + switch (e) { + case 'n': c = '\n'; break; + case 't': c = '\t'; break; + case 'r': c = '\r'; break; + case '"': case '\\': case '/': c = e; break; + case 'u': { + // Bare ASCII passthrough — drop the 4 hex digits, write '?'. + if (sc->pos + 4 <= sc->len) sc->pos += 4; + c = '?'; + break; + } + default: c = e; break; + } + } + if (w + 1 < outCap) out[w++] = c; + } + if (sc->pos >= sc->len) return false; + sc->pos++; // consume closing quote + if (w < outCap) out[w] = '\0'; + else if (outCap > 0) out[outCap - 1] = '\0'; + return true; +} + +static void SkipValue(Scanner* sc) +{ + SkipWs(sc); + if (sc->pos >= sc->len) return; + char c = sc->s[sc->pos]; + if (c == '"') { + char trash[4]; + ReadString(sc, trash, sizeof(trash)); + return; + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + int depth = 1; + sc->pos++; + while (sc->pos < sc->len && depth > 0) { + char k = sc->s[sc->pos]; + if (k == '"') { + char trash[4]; + ReadString(sc, trash, sizeof(trash)); + continue; + } + if (k == open) depth++; + else if (k == close) depth--; + sc->pos++; + } + return; + } + while (sc->pos < sc->len) { + char k = sc->s[sc->pos]; + if (k == ',' || k == '}' || k == ']') break; + sc->pos++; + } +} + +static bool ReadNumber(Scanner* sc, double* out) +{ + SkipWs(sc); + int start = sc->pos; + if (sc->pos < sc->len && (sc->s[sc->pos] == '-' || sc->s[sc->pos] == '+')) sc->pos++; + bool any = false; + while (sc->pos < sc->len) { + char c = sc->s[sc->pos]; + if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { + sc->pos++; any = true; + } else break; + } + if (!any) return false; + char tmp[64]; + int n = sc->pos - start; + if (n >= (int)sizeof(tmp)) n = (int)sizeof(tmp) - 1; + memcpy(tmp, sc->s + start, n); + tmp[n] = '\0'; + *out = strtod(tmp, NULL); + return true; +} + +static MlnlKind LookupKind(const char* s) +{ + if (strcmp(s, "channel_up") == 0) return MLNL_KIND_CHANNEL_UP; + if (strcmp(s, "channel_down") == 0) return MLNL_KIND_CHANNEL_DOWN; + if (strcmp(s, "control") == 0) return MLNL_KIND_CONTROL; + if (strcmp(s, "tx_frame") == 0) return MLNL_KIND_TX_FRAME; + if (strcmp(s, "tx_burst") == 0) return MLNL_KIND_TX_BURST; + if (strcmp(s, "assertion_passed") == 0) return MLNL_KIND_ASSERTION_PASSED; + if (strcmp(s, "assertion_failed") == 0) return MLNL_KIND_ASSERTION_FAILED; + if (strcmp(s, "impairment_fire") == 0) return MLNL_KIND_IMPAIRMENT_FIRE; + if (strcmp(s, "gain_change") == 0) return MLNL_KIND_GAIN_CHANGE; + return MLNL_KIND_UNKNOWN; +} + +const char* MlnlKindName(MlnlKind k) +{ + switch (k) { + case MLNL_KIND_CHANNEL_UP: return "channel_up"; + case MLNL_KIND_CHANNEL_DOWN: return "channel_down"; + case MLNL_KIND_CONTROL: return "control"; + case MLNL_KIND_TX_FRAME: return "tx_frame"; + case MLNL_KIND_TX_BURST: return "tx_burst"; + case MLNL_KIND_ASSERTION_PASSED: return "assertion_passed"; + case MLNL_KIND_ASSERTION_FAILED: return "assertion_failed"; + case MLNL_KIND_IMPAIRMENT_FIRE: return "impairment_fire"; + case MLNL_KIND_GAIN_CHANGE: return "gain_change"; + default: return "?"; + } +} + +// Parse a "#RRGGBB" string into r/g/b. Returns false on malformed input. +static bool ParseHexColor(const char* s, unsigned char* r, unsigned char* g, unsigned char* b) +{ + if (!s || s[0] != '#') return false; + unsigned int v = 0; + int n = 0; + for (int i = 1; i < 7 && s[i]; i++) { + char c = s[i]; + unsigned int d; + if (c >= '0' && c <= '9') d = c - '0'; + else if (c >= 'a' && c <= 'f') d = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') d = c - 'A' + 10; + else return false; + v = (v << 4) | d; + n++; + } + if (n != 6) return false; + *r = (v >> 16) & 0xff; + *g = (v >> 8) & 0xff; + *b = v & 0xff; + return true; +} + +// Returns true if the line produced an event for the array; false if it was +// the truncation sentinel or empty/malformed. +static bool ParseLine(const char* line, int n, MlnlEvent* ev, bool* truncatedOut) +{ + Scanner sc = { .s = line, .pos = 0, .len = n }; + SkipWs(&sc); + if (sc.pos >= sc.len || sc.s[sc.pos] != '{') return false; + sc.pos++; + + memset(ev, 0, sizeof(*ev)); + + // f_center/f_bw resolve into f_lo/f_hi after the line is fully parsed. + double f_center = 0, f_bw = 0; + bool have_f_lo = false, have_f_hi = false; + bool have_f_center = false, have_f_bw = false; + + for (;;) { + SkipWs(&sc); + if (sc.pos >= sc.len) return false; + if (sc.s[sc.pos] == '}') { sc.pos++; break; } + if (sc.s[sc.pos] == ',') { sc.pos++; continue; } + + char key[32] = {0}; + if (!ReadString(&sc, key, sizeof(key))) return false; + SkipWs(&sc); + if (sc.pos >= sc.len || sc.s[sc.pos] != ':') return false; + sc.pos++; + SkipWs(&sc); + + if (strcmp(key, "t_start") == 0) { + ReadNumber(&sc, &ev->t_start); + } else if (strcmp(key, "t_end") == 0) { + ReadNumber(&sc, &ev->t_end); + } else if (strcmp(key, "kind") == 0) { + ReadString(&sc, ev->kindStr, sizeof(ev->kindStr)); + ev->kind = LookupKind(ev->kindStr); + } else if (strcmp(key, "f_lo") == 0) { + ReadNumber(&sc, &ev->f_lo_hz); have_f_lo = true; + } else if (strcmp(key, "f_hi") == 0) { + ReadNumber(&sc, &ev->f_hi_hz); have_f_hi = true; + } else if (strcmp(key, "f_center") == 0) { + ReadNumber(&sc, &f_center); have_f_center = true; + } else if (strcmp(key, "f_bw") == 0) { + ReadNumber(&sc, &f_bw); have_f_bw = true; + } else if (strcmp(key, "note") == 0) { + ReadString(&sc, ev->note, sizeof(ev->note)); + ev->has_note = true; + } else if (strcmp(key, "color") == 0) { + char hex[16] = {0}; + ReadString(&sc, hex, sizeof(hex)); + if (ParseHexColor(hex, &ev->colorR, &ev->colorG, &ev->colorB)) + ev->has_color = true; + } else if (strcmp(key, "node") == 0) { + double v = 0; + if (ReadNumber(&sc, &v)) { ev->node = (unsigned int)v; ev->has_node = true; } + else SkipValue(&sc); + } else if (strcmp(key, "command") == 0) { + ReadString(&sc, ev->command, sizeof(ev->command)); + ev->has_command = true; + } else if (strcmp(key, "name") == 0) { + ReadString(&sc, ev->name, sizeof(ev->name)); + ev->has_name = true; + } else if (strcmp(key, "reason") == 0) { + ReadString(&sc, ev->reason, sizeof(ev->reason)); + ev->has_reason = true; + } else if (strcmp(key, "peak") == 0) { + ReadNumber(&sc, &ev->peak); ev->has_stats = true; + } else if (strcmp(key, "rms") == 0) { + ReadNumber(&sc, &ev->rms); ev->has_stats = true; + } else if (strcmp(key, "papr_db") == 0) { + ReadNumber(&sc, &ev->papr_db); ev->has_stats = true; + } else if (strcmp(key, "slack_s") == 0) { + ReadNumber(&sc, &ev->slack_s); ev->has_slack = true; + } else if (strcmp(key, "id") == 0) { + double v = 0; + if (ReadNumber(&sc, &v)) { ev->id = (unsigned int)v; ev->has_id = true; } + else SkipValue(&sc); + } else if (strcmp(key, "label") == 0) { + ReadString(&sc, ev->label, sizeof(ev->label)); + ev->has_label = (ev->label[0] != '\0'); + } else if (strcmp(key, "seq") == 0) { + double v = 0; + if (ReadNumber(&sc, &v)) ev->seq = (int)v; + else SkipValue(&sc); + } else if (strcmp(key, "n") == 0) { + double v = 0; + if (ReadNumber(&sc, &v)) { ev->nFrames = (int)v; ev->has_seqn = true; } + else SkipValue(&sc); + } else if (strcmp(key, "frame") == 0) { + ReadString(&sc, ev->frame, sizeof(ev->frame)); + ev->has_frame = (ev->frame[0] != '\0'); + } else if (strcmp(key, "ch") == 0) { + ReadString(&sc, ev->ch, sizeof(ev->ch)); + ev->has_ch = (ev->ch[0] != '\0'); + } else if (strcmp(key, "rate") == 0) { + ReadString(&sc, ev->rate, sizeof(ev->rate)); + ev->has_rate = (ev->rate[0] != '\0'); + } else if (strcmp(key, "sched_offset_ms") == 0) { + if (ReadNumber(&sc, &ev->sched_offset_ms)) ev->has_sched_offset = true; + else SkipValue(&sc); + } else if (strcmp(key, "truncated") == 0) { + if (sc.pos < sc.len && (sc.s[sc.pos] == 't' || sc.s[sc.pos] == 'T')) + *truncatedOut = true; + SkipValue(&sc); + } else { + // Unknown field — swallow scalar/composite alike. + SkipValue(&sc); + } + } + + // Resolve frequency: prefer f_lo/f_hi if present, else expand f_center/f_bw. + if (have_f_lo && have_f_hi) { + ev->has_freq = true; + } else if (have_f_center && have_f_bw) { + ev->f_lo_hz = f_center - f_bw * 0.5; + ev->f_hi_hz = f_center + f_bw * 0.5; + ev->has_freq = true; + } + if (ev->has_freq && ev->f_hi_hz < ev->f_lo_hz) { + double t = ev->f_lo_hz; ev->f_lo_hz = ev->f_hi_hz; ev->f_hi_hz = t; + } + + // The truncation line carries no kind; treat it as a non-event so it + // doesn't bloat the events array (the truncated flag still propagates). + if (ev->kindStr[0] == '\0') return false; + return true; +} + +// ============================================================================ +// Public API +// ============================================================================ + +bool LoadMlnlFromWav(const char* path, MlnlAnnotations* out) +{ + memset(out, 0, sizeof(*out)); + size_t len = 0; + char* payload = FindMlnlChunk(path, &len); + if (!payload) return false; + + int lineCount = 0; + for (size_t i = 0; i < len; i++) if (payload[i] == '\n') lineCount++; + if (lineCount == 0) lineCount = 1; + + out->events = (MlnlEvent*)calloc(lineCount, sizeof(MlnlEvent)); + if (!out->events) { free(payload); return false; } + + int lineStart = 0; + for (size_t i = 0; i <= len; i++) { + if (i == len || payload[i] == '\n') { + int n = (int)i - lineStart; + if (n > 0 && payload[lineStart + n - 1] == '\r') n--; + if (n > 0) { + MlnlEvent ev = {0}; + if (ParseLine(payload + lineStart, n, &ev, &out->truncated)) { + out->events[out->eventCount++] = ev; + if (ev.kind >= 0 && ev.kind < MLNL_KIND_MAX) + out->kindPresent[ev.kind] = true; + } + } + lineStart = (int)i + 1; + } + } + + out->loaded = true; + free(payload); + return true; +} + +void FreeMlnl(MlnlAnnotations* a) +{ + if (!a) return; + free(a->events); + memset(a, 0, sizeof(*a)); +} diff --git a/src/mlnl.h b/src/mlnl.h new file mode 100644 index 0000000..2c8893d --- /dev/null +++ b/src/mlnl.h @@ -0,0 +1,98 @@ +// mlnl.h - mLnL annotation chunk parser (schema v2). +// Walks a WAV file's RIFF chunks looking for the optional `mLnL` chunk +// (UTF-8 JSON Lines), parses each line into an MlnlEvent. Every event is a +// self-contained time range [t_start, t_end] (instantaneous events have +// t_start == t_end). See mlnl_chunk_spec.md. +#ifndef MLNL_H +#define MLNL_H + +#include + +typedef enum { + MLNL_KIND_UNKNOWN = 0, + MLNL_KIND_CHANNEL_UP, + MLNL_KIND_CHANNEL_DOWN, + MLNL_KIND_CONTROL, + MLNL_KIND_TX_FRAME, // v2 primary annotation: one daemon-sourced on-air frame + MLNL_KIND_TX_BURST, // deprecated, energy-detected guess; superseded by tx_frame + MLNL_KIND_ASSERTION_PASSED, + MLNL_KIND_ASSERTION_FAILED, + MLNL_KIND_IMPAIRMENT_FIRE, + MLNL_KIND_GAIN_CHANGE, +} MlnlKind; + +typedef struct { + // Required. + double t_start, t_end; + MlnlKind kind; + char kindStr[32]; // original "kind" string (for unknown kinds + display) + + // Frequency annotation (resolved from f_lo/f_hi OR f_center/f_bw). + double f_lo_hz, f_hi_hz; + bool has_freq; + + // Optional human/visual hints. + char note[96]; + bool has_note; + unsigned char colorR, colorG, colorB; // parsed from "#RRGGBB" + bool has_color; + + // Kind-specific fields. The has_* flag says whether the producer set it. + unsigned int node; + bool has_node; + char command[64]; + bool has_command; + char name[128]; // assertion name + bool has_name; + char reason[96]; // assertion_failed reason + bool has_reason; + double peak, rms, papr_db; // tx_burst + bool has_stats; + double slack_s; // assertion_passed + bool has_slack; + unsigned int id; // tx_burst: monotonic per-wav identifier + bool has_id; + char label[32]; // tx_burst: optional frame tag (SEED/BULK/ACK/...) + bool has_label; + + // tx_frame fields (schema v2 primary annotation). `node`/`has_node` above + // carry the transmitting node_id (attribution + color). + int seq, nFrames; // 0-based index + total frames in the PTT ("seq of n") + bool has_seqn; // set once `n` is present + char frame[32]; // mLink frame name (PRESENCE/SEED, BULK, BULK/RTS, ...) + bool has_frame; + char ch[16]; // daemon channel (EMERGENCY / ANNOUNCE_0|1|2 / BULK) + bool has_ch; + char rate[8]; // LDPC rate of a bulk OFDM frame ("1/2".."5/6"); else empty + bool has_rate; + // intent->air latency of this burst: air_time - modem render/intent time. + // The producer already anchors t_start/t_end on the ACTUAL on-air edge, so + // this is a profiling metric ("modem wants vs. actually does"), not a + // plotting offset. Coarse/block-quantized. See mlnl_chunk_spec.md §4.2. + double sched_offset_ms; + bool has_sched_offset; +} MlnlEvent; + +// Up to this many distinct MlnlKind values are tracked per-file (any new kinds +// added to the enum must bump this). Indexed by the MlnlKind value. +#define MLNL_KIND_MAX 16 + +typedef struct { + MlnlEvent* events; + int eventCount; + bool truncated; // {"truncated":true} sentinel appeared + bool loaded; // true iff the file contained a parseable mLnL chunk + bool kindPresent[MLNL_KIND_MAX]; // which kinds the parser saw (drives the UI) +} MlnlAnnotations; + +// Walk `path`'s RIFF chunks looking for `mLnL` and parse the JSONL payload. +// Returns true on success (out->loaded is set the same way). On failure +// (no chunk / unreadable / not WAV) returns false and zeroes *out. +bool LoadMlnlFromWav(const char* path, MlnlAnnotations* out); + +void FreeMlnl(MlnlAnnotations* a); + +// Display label for an MlnlKind. +const char* MlnlKindName(MlnlKind k); + +#endif // MLNL_H diff --git a/src/render.c b/src/render.c index 53604f3..7d5dad1 100644 --- a/src/render.c +++ b/src/render.c @@ -293,8 +293,9 @@ void DrawLabels(Rectangle bounds) DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor); } - // Frequency labels adapted to current zoom level - float maxFreq = (float)app.signal.sampleRate / 2.0f; + // Frequency labels adapted to current zoom level. Honors the display crop: + // 1.0 of the view range is the user's chosen max, not raw Nyquist. + float maxFreq = EffectiveMaxFreqHz(); float freqMin = app.view.freqStart * maxFreq; float freqMax = app.view.freqEnd * maxFreq; @@ -503,8 +504,12 @@ void DrawCursorReadout(Rectangle bounds) float tFrac = app.view.start + ((m.x - bounds.x) / bounds.width) * (app.view.end - app.view.start); float fFrac = app.view.freqStart + (1.0f - (m.y - bounds.y) / bounds.height) * (app.view.freqEnd - app.view.freqStart); float timeSec = tFrac * app.signal.duration; - float nyquist = app.signal.sampleRate * 0.5f; - float freqHz = fFrac * nyquist; + // Map cursor freq through the cropped axis (so 100% of view = chosen max, + // not raw Nyquist), but use the true Nyquist for STFT bin spacing — the + // bins themselves still cover the full signal regardless of the crop. + float displayMax = EffectiveMaxFreqHz(); + float dataNyquist = app.signal.sampleRate * 0.5f; + float freqHz = fFrac * displayMax; // Sample the STFT level at this (time, freq). char level[32] = "--"; @@ -514,7 +519,7 @@ void DrawCursorReadout(Rectangle bounds) if (seg >= app.stft.numSegments) seg = app.stft.numSegments - 1; const StftSegment* s = &app.stft.segments[seg]; if (s->spectrum && s->numBins > 1) { - float binHz = nyquist / (float)(s->numBins - 1); + float binHz = dataNyquist / (float)(s->numBins - 1); int bin = (int)(freqHz / binHz + 0.5f); if (bin < 0) bin = 0; if (bin >= s->numBins) bin = s->numBins - 1; @@ -581,8 +586,10 @@ void DrawMarkers(Rectangle bounds) if (bIn) DrawMarkerCross(b, (Color){ 255, 180, 80, 255 }, "B"); EndScissorMode(); - // Compute deltas in real units. - float nyquist = app.signal.sampleRate * 0.5f; + // Compute deltas in real units. Marker positions are normalized inside + // the displayed view, so they scale with the crop just like the freq + // axis labels do — what the user sees IS what they measure. + float nyquist = EffectiveMaxFreqHz(); float ta = app.marker.t0 * app.signal.duration, tb = app.marker.t1 * app.signal.duration; float fa = app.marker.f0 * nyquist, fb = app.marker.f1 * nyquist; float dt = fabsf(tb - ta); @@ -722,6 +729,743 @@ void DrawSpectrumPanel(Rectangle bounds) free(power); } +// ============================================================================ +// mLnL annotation overlay (schema v2) +// +// Layer order (per the spec; deepest first): +// 1. tx_frame — PRIMARY: filled translucent box + outline + per-frame label, +// each frame drawn in its own frequency lane (announce / bulk / emergency) +// 2. assertion_passed / assertion_failed — thin outlined rectangles +// 3. control / channel_up / channel_down / impairment / gain — vertical lines +// Each pass also records the topmost hover hit so the tooltip drawn last +// reflects the visually-frontmost annotation under the cursor. +// ============================================================================ + +// Default per-node palette used when an event omits the `color` field. +static Color NodeColor(unsigned int node) +{ + static const Color palette[] = { + { 120, 220, 255, 255 }, // sky + { 255, 180, 80, 255 }, // amber + { 160, 255, 140, 255 }, // mint + { 255, 130, 200, 255 }, // pink + { 200, 160, 255, 255 }, // lavender + { 255, 230, 110, 255 }, // pale gold + }; + return palette[node % (sizeof(palette) / sizeof(palette[0]))]; +} + +// Resolve an event's display color: use the producer-supplied `color` field +// if present, otherwise fall back to a per-kind default (tx_burst uses node +// palette; assertions use spec defaults; controls/etc. get a neutral hint). +static Color EventColor(const MlnlEvent* e) +{ + if (e->has_color) return (Color){ e->colorR, e->colorG, e->colorB, 255 }; + switch (e->kind) { + case MLNL_KIND_TX_FRAME: + case MLNL_KIND_TX_BURST: + return NodeColor(e->has_node ? e->node : 0); + case MLNL_KIND_ASSERTION_PASSED: return (Color){ 60, 179, 113, 255 }; // #3CB371 + case MLNL_KIND_ASSERTION_FAILED: return (Color){ 214, 40, 40, 255 }; // #D62828 + case MLNL_KIND_CONTROL: return (Color){ 255, 220, 120, 255 }; + default: return (Color){ 200, 200, 220, 255 }; + } +} + +// Map (t_s, freq_hz) to screen, honoring zoom. duration_s and nyquist_hz are +// the signal's extents. +static Vector2 AnnoToScreen(Rectangle bounds, double t_s, double f_hz, + double duration_s, double nyquist_hz) +{ + double tFrac = (duration_s > 0.0) ? (t_s / duration_s) : 0.0; + double fFrac = (nyquist_hz > 0.0) ? (f_hz / nyquist_hz) : 0.0; + double vw = app.view.end - app.view.start; + double fw = app.view.freqEnd - app.view.freqStart; + Vector2 p; + p.x = bounds.x + (float)((tFrac - app.view.start) / vw) * bounds.width; + p.y = bounds.y + bounds.height - (float)((fFrac - app.view.freqStart) / fw) * bounds.height; + return p; +} + +// Project the event's time+frequency band into a screen rectangle, clipped to +// the viewport. Events with no freq fields span the full frequency axis. +// Returns false if the result is entirely outside the visible area. +static bool EventRect(Rectangle bounds, const MlnlEvent* e, + double duration_s, double nyquist_hz, Rectangle* out) +{ + double f0 = e->has_freq ? e->f_lo_hz : 0.0; + double f1 = e->has_freq ? e->f_hi_hz : nyquist_hz; + Vector2 lo = AnnoToScreen(bounds, e->t_start, f0, duration_s, nyquist_hz); + Vector2 hi = AnnoToScreen(bounds, e->t_end, f1, duration_s, nyquist_hz); + float x = fminf(lo.x, hi.x); + float y = fminf(lo.y, hi.y); + float w = fabsf(hi.x - lo.x); + float h = fabsf(hi.y - lo.y); + if (x + w < bounds.x || x > bounds.x + bounds.width) return false; + float x0 = fmaxf(x, bounds.x); + float x1 = fminf(x + w, bounds.x + bounds.width); + float y0 = fmaxf(y, bounds.y); + float y1 = fminf(y + h, bounds.y + bounds.height); + if (x1 <= x0 || y1 <= y0) return false; + *out = (Rectangle){ x0, y0, x1 - x0, y1 - y0 }; + return true; +} + +// Build the tooltip lines for an event. Fields are listed in priority order +// (kind banner, note, time range, freq band, then kind-specific extras). +// Format a tx_frame's intent->air latency compactly: "+160ms", "+4.0s". +// sched_offset_ms = air_time - modem intent time (how late the burst hit the +// air vs. when it was rendered); see mlnl_chunk_spec.md §4.2. +static void FormatSchedOffset(double ms, char* out, int cap) +{ + const char* sign = (ms < 0) ? "-" : "+"; + double a = fabs(ms); + if (a >= 1000.0) snprintf(out, cap, "%s%.1fs", sign, a / 1000.0); + else snprintf(out, cap, "%s%.0fms", sign, a); +} + +static int BuildEventLines(const MlnlEvent* e, char lines[][96], int maxLines) +{ + int n = 0; + if (n < maxLines) snprintf(lines[n++], 96, "%s", e->kindStr); + if (e->has_note && n < maxLines) snprintf(lines[n++], 96, "%s", e->note); + + double dt = e->t_end - e->t_start; + if (dt > 0.0) { + if (n < maxLines) snprintf(lines[n++], 96, "%.3f - %.3f s", e->t_start, e->t_end); + if (n < maxLines) snprintf(lines[n++], 96, "dur: %.3f s", dt); + } else { + if (n < maxLines) snprintf(lines[n++], 96, "t: %.3f s", e->t_start); + } + if (e->has_freq && n < maxLines) snprintf(lines[n++], 96, "%.0f - %.0f Hz", e->f_lo_hz, e->f_hi_hz); + + // tx_frame specifics: frame name, daemon channel, position in the PTT, rate. + if (e->has_frame && n < maxLines) snprintf(lines[n++], 96, "frame: %s", e->frame); + if (e->has_ch && n < maxLines) snprintf(lines[n++], 96, "ch: %s", e->ch); + if (e->has_seqn && e->nFrames > 1 && n < maxLines) + snprintf(lines[n++], 96, "frame %d of %d", e->seq + 1, e->nFrames); + if (e->has_rate && n < maxLines) snprintf(lines[n++], 96, "rate: %s", e->rate); + // Intent->air latency: how late this burst hit the air vs. the modem's + // render time. Boxes are already air-anchored upstream, so this is purely + // informational (see mlnl_chunk_spec.md §4.2). + if (e->has_sched_offset && n < maxLines) { + char so[24]; + FormatSchedOffset(e->sched_offset_ms, so, sizeof(so)); + snprintf(lines[n++], 96, "sched offset: %s", so); + } + + if (e->has_node && n < maxLines) { + // For tx_burst prefer "node N