feat: headless PNG render, mLnL annotations, and per-frame sched offset

Commit the accumulated working-tree changes as one snapshot.

- Headless render: `--render OUT.png INPUT.wav` draws the spectrogram
  (full window, or `--pane` for the spectrogram pane only) to a PNG
  with no visible window. Options: `--annotations`/`--no-annotations`,
  `--annotation-opacity`, `--width`/`--height`.
- mLnL annotations: parse the optional `mLnL` RIFF chunk (schema v2)
  and render tx_frame/assertion/control overlays, a timeline lane, and
  a waveform-scope echo, with hover tooltips on the spectrogram,
  timeline, and scope.
- sched_offset_ms: parse the per-frame intent->air latency and surface
  it in the hover tooltips (boxes stay air-anchored upstream).
- Supporting: build wiring (rspektrum.make), shared types/headers,
  web-build and capture-script tweaks, and removal of the old
  synchrosqueezing LaTeX doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:19:37 -07:00
parent cef7619833
commit ac262505c1
17 changed files with 2263 additions and 257 deletions
+387
View File
@@ -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 <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// ============================================================================
// 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));
}
+98
View File
@@ -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 <stdbool.h>
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
+751 -7
View File
@@ -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 <LABEL> #id" so the operator can map back
// to the harness log even when note is identical between siblings.
if (e->has_label && e->has_id)
snprintf(lines[n++], 96, "node %u %s #%u", e->node, e->label, e->id);
else if (e->has_id)
snprintf(lines[n++], 96, "node %u #%u", e->node, e->id);
else
snprintf(lines[n++], 96, "node: %u", e->node);
} else if (e->has_id && n < maxLines) {
snprintf(lines[n++], 96, "#%u", e->id);
}
if (e->has_command && n < maxLines) snprintf(lines[n++], 96, "cmd: %s", e->command);
if (e->has_name && n < maxLines) snprintf(lines[n++], 96, "%.90s", e->name);
if (e->has_reason && n < maxLines) snprintf(lines[n++], 96, "reason: %.80s", e->reason);
if (e->has_slack && n < maxLines) snprintf(lines[n++], 96, "slack: %.2fs", e->slack_s);
if (e->has_stats) {
if (n < maxLines) snprintf(lines[n++], 96, "peak: %.3f", e->peak);
if (n < maxLines) snprintf(lines[n++], 96, "rms: %.3f", e->rms);
if (n < maxLines) snprintf(lines[n++], 96, "PAPR: %.1f dB", e->papr_db);
}
return n;
}
// Draw a label inside (or just above) `box` in `color`, scissor-clipped to
// the box's horizontal extent so long notes don't bleed over neighbors.
// topInside=true places the label inside the top of the box (used for tx_bursts
// so the box outline still reads clearly above); false places it just above,
// falling back to inside if the box is at the top of the viewport.
static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topInside)
{
if (!text || !*text || box.width < 18.0f) return;
float scale = GetUIScale();
float fs = 10.0f;
float lineH = fs * scale + 2;
int x = (int)box.x + 3;
int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH);
if (y < 0) y = (int)box.y + 2;
BeginScissorMode(x, y, (int)box.width - 4, (int)lineH);
DrawTextScaled(text, x, y, fs, color);
EndScissorMode();
}
static void DrawTooltip(Rectangle bounds, Vector2 anchor,
char lines[][96], int n, Color border)
{
if (n <= 0) return;
float scale = GetUIScale();
float fontSize = 11.0f;
float lineH = fontSize * scale + 4;
float padX = 10 * scale;
float padY = 6 * scale;
float maxW = 0;
for (int i = 0; i < n; i++) {
float w = MeasureTextScaled(lines[i], fontSize);
if (w > maxW) maxW = w;
}
int boxW = (int)(maxW + padX * 2);
int boxH = (int)(n * lineH + padY * 2);
float bx = anchor.x + 12, by = anchor.y + 12;
if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12;
if (bx < bounds.x) bx = bounds.x;
if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH;
if (by < bounds.y) by = bounds.y;
DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 220 });
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(border, 0.8f));
for (int i = 0; i < n; i++)
DrawTextScaled(lines[i], bx + padX, by + padY + i * lineH, fontSize, LIGHTGRAY);
}
static bool IsPointEvent(const MlnlEvent* e)
{
if (e->t_end > e->t_start) return false; // has a range -> draw as rect
switch (e->kind) {
case MLNL_KIND_CONTROL:
case MLNL_KIND_CHANNEL_UP:
case MLNL_KIND_CHANNEL_DOWN:
case MLNL_KIND_IMPAIRMENT_FIRE:
case MLNL_KIND_GAIN_CHANGE:
case MLNL_KIND_UNKNOWN:
return true;
default:
return true; // zero-width assertion etc. also render as vertical line
}
}
// True iff the given event is currently emphasized (selected, or hovered in
// the timeline lane). Spectrogram-cursor hover is intentionally NOT emphasized
// — otherwise simply moving the mouse over the viewport would flicker the
// overlays. Emphasis is reserved for explicit indication via the timeline.
static bool AnnotationEmphasized(int eventIndex)
{
return (app.selectedAnnotation == eventIndex) ||
(app.hoveredTimelineEvent == eventIndex);
}
static unsigned char AlphaForEvent(int eventIndex, float fillMultiplier)
{
float op = AnnotationEmphasized(eventIndex)
? app.annotationOpacityHover
: app.annotationOpacityBase;
return (unsigned char)Clamp(op * fillMultiplier, 0.0f, 255.0f);
}
// Compose a tx_frame's in-box label per spec §5: the frame name, its LDPC rate
// (bulk frames only), then its position in the PTT ("seq of n"). e.g.
// "BULK 3/4 3/6" or "PRESENCE/SEED". Falls back to the producer note.
static void FormatTxFrameLabel(const MlnlEvent* e, char* out, int cap)
{
char pos[16] = { 0 };
if (e->has_seqn && e->nFrames > 1)
snprintf(pos, sizeof(pos), " %d/%d", e->seq + 1, e->nFrames);
const char* base = e->has_frame ? e->frame : (e->has_note ? e->note : "tx_frame");
if (e->has_frame && e->has_rate)
snprintf(out, cap, "%s %s%s", base, e->rate, pos);
else
snprintf(out, cap, "%s%s", base, pos);
}
void DrawAnnotations(Rectangle bounds)
{
if (!app.loaded || !app.annotations.loaded) return;
if (!app.showAnnotations) { app.hoveredEvent = -1; return; }
if (app.signal.duration <= 0.0f) return;
app.hoveredEvent = -1;
double duration = app.signal.duration;
// Annotation freq mapping uses the DISPLAYED top-of-axis: events with
// f_lo/f_hi above the crop simply get clipped at the top of the visible
// area, the same way pan/zoom already handles off-screen events.
double nyquist = EffectiveMaxFreqHz();
Vector2 m = GetMousePosition();
bool mouseInBounds = CheckCollisionPointRec(m, bounds);
bool suppressHover = app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
app.sel.isDragging || app.view.isPanning || app.marker.dragging ||
app.markerMode;
int hoverEvent = -1;
// hover prefers later layers (assertions over bursts, point markers over both),
// which matches the spec's draw-order rationale: things on top win the click.
// ---- Layer 1: tx_burst (wash + outline) ----
// tx_burst uses the full 200 fill multiplier; emphasis comes from a higher
// per-event opacity, NOT a different multiplier.
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_BURST) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
unsigned char fillA = AlphaForEvent(i, 200.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1.5f, stroke);
DrawBoxLabel(r, e->has_note ? e->note : e->kindStr, stroke, /*topInside=*/true);
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
hoverEvent = i;
}
// ---- Layer 1b: tx_frame (PRIMARY per-frame fill + outline + label) ----
// The daemon's own per-frame self-report. Each frame carries its exact band
// (f_lo/f_hi resolved from its `ch`), so one transmission renders as a
// readable sequence of boxes laid out across the announce / bulk lanes.
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_FRAME) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
unsigned char fillA = AlphaForEvent(i, 200.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1.5f, stroke);
char lbl[64];
FormatTxFrameLabel(e, lbl, sizeof(lbl));
DrawBoxLabel(r, lbl, stroke, /*topInside=*/true);
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
hoverEvent = i;
}
// ---- Layer 2: assertion_passed / assertion_failed (outline-dominant; a
// very light wash so failed regions still tint red without blanking the
// signal underneath) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (e->t_end <= e->t_start) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
unsigned char fillA = AlphaForEvent(i, 100.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1.0f, stroke);
DrawBoxLabel(r, e->has_note ? e->note : (e->has_name ? e->name : e->kindStr),
stroke, /*topInside=*/false);
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
hoverEvent = i;
}
// ---- Layer 3: point markers (vertical lines for zero-width events) ----
float labelStaggerY = 0.0f;
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (!IsPointEvent(e)) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double tFrac = e->t_start / duration;
if (tFrac < app.view.start || tFrac > app.view.end) continue;
float x = bounds.x + (float)((tFrac - app.view.start) /
(app.view.end - app.view.start)) * bounds.width;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height), Fade(stroke, 0.6f));
DrawCircleLines((int)x, (int)bounds.y + 6, 3, stroke);
const char* lbl = e->has_note ? e->note :
e->has_command ? e->command :
e->has_name ? e->name : e->kindStr;
float lblScale = GetUIScale();
float lblFs = 10.0f;
float lblLineH = lblFs * lblScale + 2;
int labelY = (int)(bounds.y + 14 * lblScale + labelStaggerY);
int labelMaxW = (int)(bounds.x + bounds.width) - ((int)x + 4);
if (labelMaxW > 8) {
BeginScissorMode((int)x + 4, labelY, labelMaxW, (int)lblLineH);
DrawTextScaled(lbl, (int)x + 4, labelY, lblFs, stroke);
EndScissorMode();
}
labelStaggerY = (labelStaggerY > 24.0f * lblScale) ? 0.0f : labelStaggerY + lblLineH;
if (!suppressHover && mouseInBounds &&
m.x >= x - 4 && m.x <= x + 4 &&
m.y >= bounds.y && m.y <= bounds.y + bounds.height) {
hoverEvent = i;
}
}
app.hoveredEvent = hoverEvent;
// ---- Tooltip ---- Timeline hover takes priority over spectrogram hover
// (the lane is the active surface when you're hovering it).
int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent;
if (tipFor >= 0) {
const MlnlEvent* e = &app.annotations.events[tipFor];
char lines[12][96];
int n = BuildEventLines(e, lines, 12);
DrawTooltip(bounds, m, lines, n, EventColor(e));
}
if (app.annotations.truncated) {
const char* msg = "mLnL: truncated";
float fs = 10.0f;
float w = MeasureTextScaled(msg, fs);
float h = fs * GetUIScale() + 6;
DrawRectangle((int)(bounds.x + bounds.width - w - 14), (int)(bounds.y + 4),
(int)(w + 10), (int)h, (Color){ 60, 0, 0, 200 });
DrawTextScaled(msg, bounds.x + bounds.width - w - 9, bounds.y + 7,
fs, (Color){ 255, 200, 200, 255 });
}
}
// ============================================================================
// Annotation overlay on the waveform scope (time-axis only)
// ============================================================================
//
// The scope has no frequency axis so annotations render as full-height time
// bands (tx_burst + assertion) or vertical lines (point events). No hit-test:
// hover/select still happens via the timeline lane, this is purely a visual
// echo so the user can correlate the spectrogram and waveform views.
//
// Reuses AlphaForEvent / EventColor / IsPointEvent so the spectrogram and
// scope move together — selecting an event in the timeline lights it on both.
void DrawAnnotationsOnScope(Rectangle bounds)
{
if (!app.loaded || !app.annotations.loaded) return;
if (!app.showAnnotations) return;
if (app.signal.duration <= 0.0f) return;
if (bounds.width < 4 || bounds.height < 4) return;
double duration = app.signal.duration;
double viewW = app.view.end - app.view.start;
if (viewW <= 0.0) return;
// Map a time fraction (0..1) to a screen x within the scope bounds.
#define SC_X(tFrac) (bounds.x + (float)(((tFrac) - app.view.start) / viewW) * bounds.width)
// ---- Layer 1: tx_burst (full-height band) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_BURST) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (x0 < bounds.x) x0 = bounds.x;
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
if (x1 - x0 < 1) continue;
Color c = EventColor(e);
unsigned char fillA = AlphaForEvent(i, 200.0f);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
(Color){ c.r, c.g, c.b, fillA });
// Hairlines top & bottom so the band reads as deliberate framing
// rather than tinted background, even at low alpha.
DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
(Color){ c.r, c.g, c.b, strokeA });
DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
(int)x1, (int)(bounds.y + bounds.height - 1),
(Color){ c.r, c.g, c.b, strokeA });
}
// ---- Layer 1b: tx_frame (full-height band) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_FRAME) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (x0 < bounds.x) x0 = bounds.x;
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
if (x1 - x0 < 1) continue;
Color c = EventColor(e);
unsigned char fillA = AlphaForEvent(i, 200.0f);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
(Color){ c.r, c.g, c.b, fillA });
DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
(Color){ c.r, c.g, c.b, strokeA });
DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
(int)x1, (int)(bounds.y + bounds.height - 1),
(Color){ c.r, c.g, c.b, strokeA });
}
// ---- Layer 2: assertion outlines (full-height) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (e->t_end <= e->t_start) continue;
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (x0 < bounds.x) x0 = bounds.x;
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
if (x1 - x0 < 1) continue;
Color c = EventColor(e);
unsigned char fillA = AlphaForEvent(i, 100.0f);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
Rectangle r = { x0, bounds.y, x1 - x0, bounds.height };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1, (Color){ c.r, c.g, c.b, strokeA });
}
// ---- Layer 3: point events as vertical lines ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (!IsPointEvent(e)) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double t0f = e->t_start / duration;
if (t0f < app.view.start || t0f > app.view.end) continue;
float x = SC_X(t0f);
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height),
Fade((Color){ c.r, c.g, c.b, strokeA }, 0.6f));
}
// ---- Hover hit-test + tooltip ----
// The scope only carries a time axis, so a hit is purely horizontal: the
// mouse x falls inside an event's band (range events) or near its line
// (point events). Narrowest match wins so a short event inside a wide band
// is still reachable. Pops the same detail tooltip as the spectrogram, so
// the sched-offset / metadata is available from either view.
Vector2 m = GetMousePosition();
bool suppress = app.sel.isDragging || app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
app.view.isPanning || app.marker.dragging;
if (!suppress && CheckCollisionPointRec(m, bounds)) {
int hover = -1;
double bestW = 1e18;
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (IsPointEvent(e)) {
double t0f = e->t_start / duration;
if (t0f < app.view.start || t0f > app.view.end) continue;
float x = SC_X(t0f);
if (m.x >= x - 4 && m.x <= x + 4) { bestW = 0.0; hover = i; }
} else if (e->kind == MLNL_KIND_TX_BURST || e->kind == MLNL_KIND_TX_FRAME ||
e->kind == MLNL_KIND_ASSERTION_PASSED || e->kind == MLNL_KIND_ASSERTION_FAILED) {
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (m.x >= x0 && m.x <= x1) {
double w = t1f - t0f;
if (w <= bestW) { bestW = w; hover = i; }
}
}
}
if (hover >= 0) {
const MlnlEvent* e = &app.annotations.events[hover];
char lines[12][96];
int n = BuildEventLines(e, lines, 12);
DrawTooltip(bounds, m, lines, n, EventColor(e));
}
}
#undef SC_X
}
// ============================================================================
// Timeline lane
// ============================================================================
// Count enabled-and-present annotation kinds. Mirrors the helper in
// spectrogram.c — kept local because static functions don't cross translation
// units; if it grows beyond two callers move it into a shared module.
static int CountVisibleAnnotationKindsLocal(void)
{
int n = 0;
for (int k = 0; k < MLNL_KIND_MAX; k++)
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
return n;
}
// Sort items by event duration descending: widest first, narrowest last.
// We draw in that order so the narrowest event ends up on top, and our
// hit-test (which keeps the *last* hit) naturally picks the narrowest event
// under the cursor — exactly what the user asked for so big chunks can't
// wash out short events sitting inside them.
typedef struct { int idx; double dur; } TlSortItem;
static int CmpTlSortDesc(const void* a, const void* b)
{
double da = ((const TlSortItem*)a)->dur, db = ((const TlSortItem*)b)->dur;
if (da > db) return -1;
if (da < db) return 1;
return 0;
}
// Row index (0..numRows-1) for a kind in the expanded timeline. -1 if hidden.
static int TimelineRowForKind(int kind)
{
if (kind < 0 || kind >= MLNL_KIND_MAX) return -1;
if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) return -1;
int r = 0;
for (int k = 0; k < kind; k++)
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) r++;
return r;
}
void DrawTimeline(Rectangle lane)
{
if (!app.annotations.loaded || !app.showAnnotations) return;
if (app.annotations.eventCount == 0) return;
if (lane.width < 8 || lane.height < 4) return;
// Background. Slightly different from the spectrogram so the lane reads
// as a separate UI surface rather than blending into the viewport.
DrawRectangleRec(lane, (Color){ 8, 10, 14, 235 });
DrawRectangleLinesEx(lane, 1, Fade(GRAY, 0.35f));
double duration = app.signal.duration;
if (duration <= 0) return;
// Reserve a narrow chevron strip on the right for the expand/collapse toggle.
float chevW = 14.0f * GetUIScale();
Rectangle chev = { lane.x + lane.width - chevW - 2, lane.y, chevW, lane.height };
Rectangle plot = { lane.x + 2, lane.y + 1, lane.width - chevW - 6, lane.height - 2 };
Vector2 m = GetMousePosition();
bool mouseInLane = CheckCollisionPointRec(m, lane);
bool mouseInPlot = CheckCollisionPointRec(m, plot);
bool mouseInChev = CheckCollisionPointRec(m, chev);
app.hoveredTimelineEvent = -1;
// Build a list of visible event indices (those not filtered by the
// per-kind checkboxes) sorted by duration descending so we draw widest
// first / narrowest last (last hit wins on hover).
int eventCount = app.annotations.eventCount;
TlSortItem* items = (TlSortItem*)malloc((size_t)eventCount * sizeof(TlSortItem));
if (!items) return;
int nVisible = 0;
for (int i = 0; i < eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
items[nVisible].idx = i;
items[nVisible].dur = e->t_end - e->t_start;
nVisible++;
}
qsort(items, nVisible, sizeof(TlSortItem), CmpTlSortDesc);
// Helper: time (seconds) -> screen X within plot.
#define TL_X(t) (plot.x + (float)((t) / duration) * plot.width)
if (!app.timelineExpanded) {
// Single mixed strip. Each event is a colored rect spanning [t0,t1]
// (point events get a 2px tick). Narrowest-on-top is implicit in the
// sort order.
for (int k = 0; k < nVisible; k++) {
int i = items[k].idx;
const MlnlEvent* e = &app.annotations.events[i];
Color c = EventColor(e);
float x0 = TL_X(e->t_start);
float x1 = TL_X(e->t_end);
float w = x1 - x0;
if (w < 2.0f) w = 2.0f; // visibility minimum for narrow events
Rectangle r = { x0, plot.y, w, plot.height };
if (app.selectedAnnotation == i)
DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
DrawRectangleRec(r, c);
if (mouseInPlot && CheckCollisionPointRec(m, r))
app.hoveredTimelineEvent = i;
}
} else {
// One row per enabled kind. Draw row labels + separators first, then
// events on top (still narrowest-last).
int numRows = CountVisibleAnnotationKindsLocal();
if (numRows < 1) numRows = 1;
float rowH = plot.height / (float)numRows;
for (int kind = 0, row = 0; kind < MLNL_KIND_MAX; kind++) {
if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) continue;
float ry = plot.y + row * rowH;
if (row > 0)
DrawLine((int)plot.x, (int)ry, (int)(plot.x + plot.width), (int)ry, Fade(GRAY, 0.2f));
DrawTextScaled(MlnlKindName((MlnlKind)kind), plot.x + 4, ry + 1, 9,
Fade(LIGHTGRAY, 0.7f));
row++;
}
for (int k = 0; k < nVisible; k++) {
int i = items[k].idx;
const MlnlEvent* e = &app.annotations.events[i];
int row = TimelineRowForKind(e->kind);
if (row < 0) continue;
Color c = EventColor(e);
float x0 = TL_X(e->t_start);
float x1 = TL_X(e->t_end);
float w = x1 - x0;
if (w < 2.0f) w = 2.0f;
float ry = plot.y + row * rowH + 1;
float rh = rowH - 2;
Rectangle r = { x0, ry, w, rh };
if (app.selectedAnnotation == i)
DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
DrawRectangleRec(r, c);
if (mouseInPlot && CheckCollisionPointRec(m, r))
app.hoveredTimelineEvent = i;
}
}
free(items);
#undef TL_X
// Chevron toggle (separate hit area from the plot so it always responds).
if (mouseInChev) DrawRectangleRec(chev, Fade(GRAY, 0.25f));
float chevFs = 10.0f;
DrawTextScaled(app.timelineExpanded ? "v" : ">",
chev.x + 3, chev.y + chev.height * 0.5f - chevFs * GetUIScale() * 0.5f,
chevFs, LIGHTGRAY);
if (mouseInChev && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
app.timelineExpanded = !app.timelineExpanded;
// Click handling. In collapsed mode the lane is small + low-resolution,
// so any click in the plot expands rather than risking an accidental
// selection. Selection is an "expanded mode" gesture.
if (mouseInPlot && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
if (!app.timelineExpanded) {
app.timelineExpanded = true;
} else if (app.hoveredTimelineEvent >= 0) {
app.selectedAnnotation = (app.selectedAnnotation == app.hoveredTimelineEvent)
? -1 : app.hoveredTimelineEvent;
} else {
app.selectedAnnotation = -1;
}
}
// Subtle hint when hovering a collapsed lane (helps the user discover
// the expand affordance the first time they see annotations).
if (!app.timelineExpanded && mouseInLane) {
int kinds = CountVisibleAnnotationKindsLocal();
if (kinds > 0) {
char hint[48];
snprintf(hint, sizeof(hint), "%d kinds — click to expand", kinds);
float hintFs = 9.0f;
float w = MeasureTextScaled(hint, hintFs);
DrawTextScaled(hint, plot.x + plot.width - w - 4, plot.y - 1,
hintFs, Fade(LIGHTGRAY, 0.85f));
}
}
}
// ============================================================================
// Playhead
// ============================================================================
+7
View File
@@ -29,5 +29,12 @@ void DrawCursorReadout(Rectangle bounds);
void DrawMarkers(Rectangle bounds);
void DrawSpectrumPanel(Rectangle bounds);
void DrawPlayhead(Rectangle bounds);
void DrawAnnotations(Rectangle bounds);
// Annotation timeline lane. Updates app.hoveredTimelineEvent and
// app.selectedAnnotation in response to mouse interaction in `lane`.
void DrawTimeline(Rectangle lane);
// Time-only annotation overlay rendered on top of the waveform scope.
// Shares opacity/selection state with the spectrogram overlay.
void DrawAnnotationsOnScope(Rectangle scopeBounds);
#endif // RENDER_H
+485 -23
View File
@@ -91,10 +91,22 @@ typedef struct {
float vScrollbarWidth;
float topMargin;
float bottomMargin;
float spectroHeight; // height of the spectrogram (respects the scope divider)
Rectangle viewBounds; // the spectrogram drawing area
float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane)
float timelineHeight; // 0 if no annotations / lane hidden
Rectangle viewBounds; // the spectrogram drawing area
Rectangle timelineBounds;// the annotations timeline lane (zero-sized if not shown)
} Layout;
// Number of MlnlKind rows that should be visible in the expanded timeline:
// kinds present in this file AND not filtered out by the per-kind checkboxes.
static int CountVisibleAnnotationKinds(void)
{
int n = 0;
for (int k = 0; k < MLNL_KIND_MAX; k++)
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
return n;
}
static Layout ComputeLayout(void)
{
Layout L;
@@ -107,12 +119,35 @@ static Layout ComputeLayout(void)
L.topMargin = 50 * L.scale;
L.bottomMargin = 10 * L.scale;
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
// Timeline lane sits above the spectrogram, eating from spectro height
// (not from the scope area). Collapsed is a thin sparkline; expanded grows
// by one row per enabled kind. Only present when the file carries
// annotations and the master toggle is on.
L.timelineHeight = 0;
if (app.annotations.loaded && app.annotations.eventCount > 0 && app.showAnnotations) {
if (app.timelineExpanded) {
int rows = CountVisibleAnnotationKinds();
if (rows < 1) rows = 1;
L.timelineHeight = (rows * 14.0f + 4.0f) * L.scale;
} else {
L.timelineHeight = 10.0f * L.scale;
}
}
float laneX = L.sidebarWidth + L.freqLabelWidth;
float laneW = GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale;
L.timelineBounds = (Rectangle){ laneX, L.topMargin, laneW, L.timelineHeight };
// Spectrogram starts below the lane (with a 2-pixel gap) and shrinks accordingly.
float gap = (L.timelineHeight > 0) ? 2.0f * L.scale : 0.0f;
L.viewBounds = (Rectangle){
L.sidebarWidth + L.freqLabelWidth,
L.topMargin,
GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale,
L.spectroHeight
laneX,
L.topMargin + L.timelineHeight + gap,
laneW,
L.spectroHeight - L.timelineHeight - gap
};
L.spectroHeight = L.viewBounds.height;
return L;
}
@@ -160,6 +195,245 @@ void ResetForNewSignal(void)
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
// Drop the previous file's annotations; the caller re-parses from the new
// path after this returns (LoadMlnlFromWav needs the source path that
// raylib's LoadWave already consumed).
FreeMlnl(&app.annotations);
app.hoveredEvent = -1;
app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1;
app.autocropPending = true; // run once when this file's STFT is ready
}
// ============================================================================
// Auto-crop: shrink the displayed freq axis + time view to where the data
// actually lives. Two independent sources, tried in priority order.
// ============================================================================
// 15% headroom above the highest annotated f_hi keeps event boxes from
// touching the top edge; 5% time padding gives breathing room around the
// outermost events without pushing them into the corners.
#define AUTOCROP_FREQ_HEADROOM 1.15f
#define AUTOCROP_TIME_PAD_FRAC 0.05f
// Confidence thresholds for the energy heuristic. If the cropped freq band
// would still cover >80% of Nyquist, or the cropped time would cover >90%
// of the timeline, the signal genuinely uses most of the available range
// and we leave the view alone (the crop would only be churn).
#define AUTOCROP_FREQ_MAX_FRAC 0.80f
#define AUTOCROP_TIME_MAX_FRAC 0.90f
// Cumulative energy fraction that defines "where signal lives". 0.99 means
// the cropped freq range holds 99% of the spectrogram's total power.
#define AUTOCROP_FREQ_ENERGY 0.99
// Per-segment activity threshold (fraction of the peak segment's energy).
// Anything below this is treated as silence at the timeline edges.
#define AUTOCROP_TIME_ACTIVITY 0.01
// Annotation-driven crop: trusts the producer. Always confident when ≥1
// annotation has f_hi or any have a non-zero time span. Returns the computed
// crop in the out-params; leaves them at "no crop" values on failure.
static bool ComputeAnnotationCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
*outFreqMaxHz = 0.0f;
*outViewStart = 0.0f; *outViewEnd = 1.0f;
if (!app.annotations.loaded || app.annotations.eventCount == 0) return false;
double fMax = 0.0;
double tMin = 1e18, tMax = -1e18;
bool anyFreq = false, anyTime = false;
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->has_freq && e->f_hi_hz > fMax) { fMax = e->f_hi_hz; anyFreq = true; }
if (e->t_end >= e->t_start) {
if (e->t_start < tMin) tMin = e->t_start;
if (e->t_end > tMax) tMax = e->t_end;
anyTime = true;
}
}
if (anyFreq) *outFreqMaxHz = (float)(fMax * AUTOCROP_FREQ_HEADROOM);
if (anyTime && app.signal.duration > 0.0f) {
double pad = (tMax - tMin) * AUTOCROP_TIME_PAD_FRAC;
double s = tMin - pad, e = tMax + pad;
if (s < 0.0) s = 0.0;
if (e > app.signal.duration) e = app.signal.duration;
if (e > s) {
*outViewStart = (float)(s / app.signal.duration);
*outViewEnd = (float)(e / app.signal.duration);
}
}
return anyFreq || anyTime;
}
// Energy heuristic: walk the STFT, build per-bin and per-segment energy.
// Crop freq if 99% of energy fits below 80% of Nyquist; crop time if the
// activity envelope occupies <90% of the timeline. Returns true if at least
// one axis was confidently cropped.
static bool ComputeEnergyCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
*outFreqMaxHz = 0.0f;
*outViewStart = 0.0f; *outViewEnd = 1.0f;
if (app.stft.numSegments < 2) return false;
int nbins = 0;
for (int s = 0; s < app.stft.numSegments; s++) {
if (app.stft.segments[s].spectrum && app.stft.segments[s].numBins > nbins)
nbins = app.stft.segments[s].numBins;
}
if (nbins < 4) return false;
int nsegs = app.stft.numSegments;
double* binE = (double*)calloc((size_t)nbins, sizeof(double));
double* segE = (double*)calloc((size_t)nsegs, sizeof(double));
if (!binE || !segE) { free(binE); free(segE); return false; }
double totalE = 0.0, segPeak = 0.0;
for (int s = 0; s < nsegs; s++) {
if (!app.stft.segments[s].spectrum) continue;
int nb = app.stft.segments[s].numBins;
for (int b = 0; b < nb; b++) {
double a = app.stft.segments[s].spectrum[b].amplitude;
double e = a * a;
binE[b] += e;
segE[s] += e;
totalE += e;
}
if (segE[s] > segPeak) segPeak = segE[s];
}
bool didCrop = false;
// --- Freq axis: smallest bin whose cumulative energy reaches 99%. ---
if (totalE > 0.0) {
double thr = totalE * AUTOCROP_FREQ_ENERGY;
double cum = 0.0;
int cropBin = nbins - 1;
for (int b = 0; b < nbins; b++) {
cum += binE[b];
if (cum >= thr) { cropBin = b; break; }
}
float fraction = (float)cropBin / (float)(nbins - 1);
if (fraction <= AUTOCROP_FREQ_MAX_FRAC) {
float nyq = app.signal.sampleRate * 0.5f;
*outFreqMaxHz = fraction * nyq * AUTOCROP_FREQ_HEADROOM;
didCrop = true;
}
}
// --- Time axis: activity envelope at 1% of segment-peak energy. ---
if (segPeak > 0.0) {
double thr = segPeak * AUTOCROP_TIME_ACTIVITY;
int first = -1, last = -1;
for (int s = 0; s < nsegs; s++) {
if (segE[s] >= thr) { if (first < 0) first = s; last = s; }
}
if (first >= 0 && last > first) {
float coverage = (float)(last - first + 1) / (float)nsegs;
if (coverage <= AUTOCROP_TIME_MAX_FRAC) {
float s0 = (float)first / (float)nsegs;
float s1 = (float)(last + 1) / (float)nsegs;
float pad = (s1 - s0) * AUTOCROP_TIME_PAD_FRAC;
s0 -= pad; s1 += pad;
if (s0 < 0.0f) s0 = 0.0f;
if (s1 > 1.0f) s1 = 1.0f;
*outViewStart = s0;
*outViewEnd = s1;
didCrop = true;
}
}
}
free(binE); free(segE);
return didCrop;
}
void ApplyAutoCrop(void)
{
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return;
float nyq = app.signal.sampleRate * 0.5f;
// Compute BOTH heuristics for BOTH axes, then pick the more focused
// result per axis. Annotations can be authoritative for freq (the
// producer knows the band) yet wide for time (a single late `control`
// marker can span almost the whole file even if signal activity ended
// long before) — so we don't tie the time choice to the freq choice.
float aFreq = 0.0f, aStart = 0.0f, aEnd = 1.0f;
float eFreq = 0.0f, eStart = 0.0f, eEnd = 1.0f;
ComputeAnnotationCrop(&aFreq, &aStart, &aEnd);
ComputeEnergyCrop(&eFreq, &eStart, &eEnd);
// ---- Freq axis: smaller cropped max wins. ----
// Both candidates are 0 when the source didn't propose a crop; treat
// those as "didn't propose" rather than "crop to 0".
float freqMax = 0.0f;
const char* freqSrc = NULL;
if (aFreq > 0.0f && eFreq > 0.0f) {
if (eFreq < aFreq) { freqMax = eFreq; freqSrc = "energy"; }
else { freqMax = aFreq; freqSrc = "annotations"; }
} else if (aFreq > 0.0f) { freqMax = aFreq; freqSrc = "annotations"; }
else if (eFreq > 0.0f) { freqMax = eFreq; freqSrc = "energy"; }
// ---- Time axis: more focused (shorter) range wins. ----
// Each source's output is a 0..1 fraction of the signal duration; a
// value of [0..1] means "didn't crop". We bias against picking a source
// that's effectively the whole timeline.
bool aShrunk = (aEnd - aStart) < 0.999f;
bool eShrunk = (eEnd - eStart) < 0.999f;
float vStart = 0.0f, vEnd = 1.0f;
const char* timeSrc = NULL;
if (aShrunk && eShrunk) {
if ((eEnd - eStart) < (aEnd - aStart)) {
vStart = eStart; vEnd = eEnd; timeSrc = "energy";
} else {
vStart = aStart; vEnd = aEnd; timeSrc = "annotations";
}
} else if (aShrunk) { vStart = aStart; vEnd = aEnd; timeSrc = "annotations"; }
else if (eShrunk) { vStart = eStart; vEnd = eEnd; timeSrc = "energy"; }
bool freqChanged = (freqMax > 0.0f && freqMax < nyq * 0.99f);
bool timeChanged = (timeSrc != NULL);
if (!freqChanged && !timeChanged) {
TraceLog(LOG_INFO, "Auto-crop: no confident source, leaving view alone");
return;
}
if (freqChanged) {
if (freqMax > nyq) freqMax = nyq;
app.displayMaxFreqHz = freqMax;
}
if (timeChanged) {
app.view.start = vStart;
app.view.end = vEnd;
}
// Fit freq view to the cropped band; otherwise a prior zoom would
// double-zoom on top of the new crop.
app.view.freqStart = 0.0f;
app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
// Splash message — mention per-axis source separately when they diverge
// (e.g. freq from annotations, time from energy on a file with a stray
// late control event).
char freqPart[64] = "", timePart[64] = "";
if (freqChanged) snprintf(freqPart, sizeof(freqPart), "0-%.0f Hz", freqMax);
if (timeChanged) snprintf(timePart, sizeof(timePart), "%.2f-%.2f s",
vStart * app.signal.duration, vEnd * app.signal.duration);
char srcPart[80];
if (freqChanged && timeChanged && freqSrc && timeSrc && strcmp(freqSrc, timeSrc) != 0) {
snprintf(srcPart, sizeof(srcPart), "freq: %s, time: %s", freqSrc, timeSrc);
} else {
const char* s = freqSrc ? freqSrc : timeSrc;
snprintf(srcPart, sizeof(srcPart), "%s", s ? s : "auto");
}
snprintf(app.autocropNoticeMsg, sizeof(app.autocropNoticeMsg),
"View auto-cropped to %s%s%s (%s).",
freqPart,
(freqChanged && timeChanged) ? ", " : "",
timePart,
srcPart);
app.autocropNoticeActive = true;
TraceLog(LOG_INFO, "Auto-crop: %s", app.autocropNoticeMsg);
}
// ============================================================================
@@ -234,6 +508,69 @@ static void DispatchKeymap(void)
int main(int argc, char* argv[])
{
// ---- Command-line arguments ----
// Two modes:
// GUI: rspektrum [input.wav]
// Headless: rspektrum --render OUT.png INPUT.wav [options]
// The headless path computes the spectrogram, draws annotations, writes a
// PNG, and exits without ever showing a window (FLAG_WINDOW_HIDDEN keeps a
// GL context for rendering but puts nothing on screen).
const char* inputArg = NULL; // input WAV (positional)
const char* renderOut = NULL; // --render target; non-NULL => headless mode
bool headless = false;
int annoChoice = -1; // -1 = auto (show if present), 0 = off, 1 = on
float annoOpacity = -1.0f; // <0 = keep default; else override resting overlay alpha
bool paneOnly = false; // crop to the spectrogram pane (no sidebar/scope)
int reqW = 1280, reqH = 800; // headless output size
for (int i = 1; i < argc; i++) {
const char* a = argv[i];
if ((strcmp(a, "--render") == 0 || strcmp(a, "-r") == 0) && i + 1 < argc) {
renderOut = argv[++i];
headless = true;
} else if (strcmp(a, "--annotations") == 0 || strcmp(a, "-a") == 0) {
annoChoice = 1;
} else if (strcmp(a, "--no-annotations") == 0) {
annoChoice = 0;
} else if (strncmp(a, "--annotation-opacity=", 21) == 0) {
annoOpacity = (float)atof(a + 21);
} else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) {
annoOpacity = (float)atof(argv[++i]);
} else if (strcmp(a, "--pane") == 0) {
paneOnly = true;
} else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
reqW = atoi(argv[++i]);
} else if (strcmp(a, "--height") == 0 && i + 1 < argc) {
reqH = atoi(argv[++i]);
} else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) {
printf(
"rspektrum - spectrogram viewer\n\n"
"Usage:\n"
" rspektrum [input.wav] open the GUI\n"
" rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n"
"Headless options:\n"
" -r, --render OUT.png render a screenshot to OUT.png (no window)\n"
" -a, --annotations force the annotation overlay on\n"
" --no-annotations force the annotation overlay off\n"
" (default: shown when the WAV carries annotations)\n"
" --annotation-opacity=V resting overlay alpha 0..1 (default 0.06, faint)\n"
" --pane capture only the spectrogram pane (no sidebar/scope)\n"
" --width N output width (default 1280)\n"
" --height N output height (default 800)\n"
" -h, --help show this help\n");
return 0;
} else if (a[0] != '-') {
if (!inputArg) inputArg = a;
}
}
if (reqW < 16) reqW = 1280;
if (reqH < 16) reqH = 800;
if (annoOpacity > 1.0f) annoOpacity = 1.0f;
if (headless && !inputArg) {
fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
return 2;
}
#ifdef __EMSCRIPTEN__
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
// callback sets the screen size to window.innerWidth, but the GLFW window-
@@ -244,9 +581,16 @@ int main(int argc, char* argv[])
// resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
#else
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
if (headless) {
// Hidden window: keeps a GL context for rendering the frame, but
// nothing is ever shown. No HIGHDPI so the framebuffer matches the
// requested size exactly (LoadImageFromScreen reads it back 1:1).
SetConfigFlags(FLAG_WINDOW_HIDDEN);
} else {
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
}
#endif
InitWindow(1280, 800, "Spectrogram Viewer");
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
SetTargetFPS(60);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
InitAudioDevice();
@@ -309,6 +653,18 @@ int main(int argc, char* argv[])
}
app.isPlaying = false;
app.playbackFinished = false;
app.displayMaxFreqHz = 0.0f; // 0 = no crop; user sets via sidebar slider
app.showAnnotations = true;
app.annotationsExpanded = false;
app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins
app.annotationOpacityHover = 0.65f; // pop on hover / selection
// CLI override: there's no hover in a headless render, so the resting alpha
// governs every overlay — bump it to make annotations read in the PNG.
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
app.timelineExpanded = false;
app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1;
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
app.showScope = true;
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
app.isDividing = false;
@@ -326,27 +682,71 @@ int main(int argc, char* argv[])
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
bool fileLoaded = false;
if (argc > 1) {
TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]);
if (inputArg) {
TraceLog(LOG_INFO, "Loading file from command line: %s", inputArg);
char resolvedPath[8192] = { 0 };
// If the path doesn't exist as-is, try prepending original dir
if (!FileExists(argv[1]) && originalDir[0]) {
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, argv[1]);
if (!FileExists(inputArg) && originalDir[0]) {
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, inputArg);
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
}
const char* pathToLoad = FileExists(argv[1]) ? argv[1] : resolvedPath;
const char* pathToLoad = FileExists(inputArg) ? inputArg : resolvedPath;
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
fileLoaded = true;
ResetForNewSignal();
LoadMlnlFromWav(pathToLoad, &app.annotations);
TraceLog(LOG_INFO, "File loaded successfully");
}
}
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
while (!WindowShouldClose())
// ---- Headless render setup ----
// Compute the spectrogram synchronously here; the frame is drawn and
// captured at the bottom of the (single-pass) main loop below. headlessRc
// gates the loop: a load failure skips it entirely.
int headlessRc = 0;
char headlessOut[8192] = { 0 };
if (headless) {
if (!fileLoaded) {
fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
headlessRc = 1;
} else {
// Resolve the output path relative to the launch dir (CWD was
// changed to resources/ by SearchAndSetResourceDir).
if (renderOut[0] == '/') {
snprintf(headlessOut, sizeof(headlessOut), "%s", renderOut);
} else {
snprintf(headlessOut, sizeof(headlessOut), "%s/%s", originalDir, renderOut);
}
if (annoChoice == 0) app.showAnnotations = false;
else if (annoChoice == 1) app.showAnnotations = true;
// Compute the full-resolution STFT in one shot (no incremental /
// background passes — there is no interactive loop to spread them
// over). Mirrors the Emscripten single-shot path above.
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1;
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.currentSTFTSegment = app.stft.numSegments;
app.bgHighResSeg = app.stft.numSegments;
app.stftComputed = true;
app.highResFinished = true;
app.bgFinished = true;
app.isBgProcessing = false;
app.loadingPhase = 0;
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
app.autocropNoticeActive = false; // don't draw the crop splash into the shot
app.exportMessage[0] = '\0';
}
}
while (!WindowShouldClose() && headlessRc == 0)
{
#ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop).
@@ -362,6 +762,7 @@ int main(int argc, char* argv[])
if (isWav && FileExists(dropped.paths[0])) {
if (LoadWavFile(dropped.paths[0], &app.signal)) {
ResetForNewSignal();
LoadMlnlFromWav(dropped.paths[0], &app.annotations);
}
}
}
@@ -606,7 +1007,12 @@ int main(int argc, char* argv[])
Vector2 mousePos = GetMousePosition();
// Calculate divider screen position (for hover detection)
float dividerScreenY = selTopMargin + selSpectroHeight;
// Divider is drawn at the BOTTOM of the spectrogram viewport. With the
// timeline lane occupying space above the viewport, viewBounds.y was
// shifted down — so the divider's actual screen Y is `viewBounds.y +
// viewBounds.height`, NOT `topMargin + spectroHeight` (those two used
// to be equal before the lane existed).
float dividerScreenY = selBounds.y + selBounds.height;
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
@@ -817,6 +1223,7 @@ int main(int argc, char* argv[])
app.isBgProcessing = false;
app.loadingPhase = 0;
SaveToCache();
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
#else
if (app.loadingPhase == 0) {
// Initialize STFT once
@@ -855,6 +1262,11 @@ int main(int argc, char* argv[])
app.stft.numSegments, app.skipFactor);
// Save the overview result to cache (will be overwritten when full-res completes)
SaveToCache();
// Run auto-crop now that we have both annotations (loaded right
// after LoadWavFile) AND an STFT (for the energy fallback).
// Gated on autocropPending so an FFT-size change (which routes
// through the same loadingPhase=2 block) doesn't re-fire it.
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
}
#endif // __EMSCRIPTEN__
}
@@ -950,9 +1362,14 @@ int main(int argc, char* argv[])
int visibleEndX = (int)(app.view.end * imgWidth);
int visibleWidth = visibleEndX - visibleStartX;
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max)
int visibleStartY = (int)((1.0f - app.view.freqEnd) * imgHeight);
int visibleEndY = (int)((1.0f - app.view.freqStart) * imgHeight);
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
// The display-crop slider maps view.freqStart/End=1.0 to a fraction
// of the texture's height < 1.0, effectively zooming the freq axis
// so the cropped band fills the viewport while leaving the source
// texture untouched.
float cropFrac = DisplayFreqFraction();
int visibleStartY = (int)((1.0f - app.view.freqEnd * cropFrac) * imgHeight);
int visibleEndY = (int)((1.0f - app.view.freqStart * cropFrac) * imgHeight);
int visibleHeight = visibleEndY - visibleStartY;
// Invalidate cache if view changed or texture not valid
@@ -1054,18 +1471,25 @@ int main(int argc, char* argv[])
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
// Timeline lane sits above the spectrogram. Drawn before the
// overlays so its hover/selection state is set for the same frame.
if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds);
DrawAnnotations(viewBounds);
DrawSelection(viewBounds);
DrawSelectionDrag(viewBounds);
DrawMarkers(viewBounds);
DrawPlayhead(viewBounds);
DrawLabels(viewBounds);
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
if (!UiModalOpen() && app.hoveredEvent < 0 && app.hoveredTimelineEvent < 0)
DrawCursorReadout(viewBounds);
DrawSpectrumPanel(viewBounds);
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float maxFreq = EffectiveMaxFreqHz();
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
// Pin to the top margin so the timeline lane (which lives between
// the banner and the spectrogram) doesn't shove the banner down.
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
viewBounds.x, topMargin - 30, 20, LIGHTGRAY);
// Draw waveform scope view underneath the spectrogram
if (app.showScope && app.loaded && app.signal.samples != NULL) {
@@ -1088,6 +1512,11 @@ int main(int argc, char* argv[])
} else {
DrawScopeView(&app.scopeView, -1.0f);
}
// Echo the annotation overlay onto the scope so selecting an
// event in the timeline highlights both surfaces at once.
DrawAnnotationsOnScope((Rectangle){
(float)app.scopeView.x, (float)app.scopeView.y,
(float)app.scopeView.width, (float)app.scopeView.height });
// Scope label, tucked inside the top-left so it clears the time
// axis labels and scrollbar that sit in the band above the scope.
DrawTextScaled("Waveform", viewBounds.x + 4 * renderScale, app.scopeView.y + 3 * renderScale,
@@ -1143,6 +1572,10 @@ int main(int argc, char* argv[])
// Draw file browser on top (if active)
if (app.showFileBrowser) DrawFileBrowser();
// Auto-crop notice modal — drawn below About so About still wins if
// both happened to be up at once (shouldn't happen in practice).
DrawAutocropNotice();
// About / help dialog (topmost)
DrawAboutDialog();
@@ -1165,8 +1598,36 @@ int main(int argc, char* argv[])
}
EndDrawing();
// Headless: the frame is now fully rendered. Read it back, optionally
// crop to the spectrogram pane, write the PNG, and stop the loop.
if (headless) {
Image shot = LoadImageFromScreen();
if (paneOnly) {
// Crop to the spectrogram pane: freq labels + banner + timeline
// lane + spectrogram + time-axis labels. Drops sidebar + scope.
Layout capL = ComputeLayout();
Rectangle vb = capL.viewBounds;
float top = capL.topMargin - 30.0f;
if (top < 0.0f) top = 0.0f;
float left = capL.sidebarWidth;
float right = vb.x + vb.width + capL.vScrollbarWidth + 10.0f * capL.scale;
float bottom = vb.y + vb.height + capL.labelHeight + 4.0f * capL.scale;
ImageCrop(&shot, (Rectangle){ left, top, right - left, bottom - top });
}
int outW = shot.width, outH = shot.height;
bool ok = ExportImage(shot, headlessOut);
UnloadImage(shot);
if (ok) {
printf("Wrote %s (%dx%d)\n", headlessOut, outW, outH);
} else {
fprintf(stderr, "rspektrum: failed to write '%s'\n", headlessOut);
headlessRc = 1;
}
break;
}
}
TraceLog(LOG_INFO, "Shutting down...");
if (mainFont.texture.id != 0) UnloadFont(mainFont);
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
@@ -1178,8 +1639,9 @@ int main(int argc, char* argv[])
FreeBrowserFiles();
FreeAllCacheEntries(&app.fftCache);
free(app.reassignBuffer);
FreeMlnl(&app.annotations);
FreeSignal(&app.signal);
CloseAudioDevice();
CloseWindow();
return 0;
return headlessRc;
}
+78 -1
View File
@@ -6,6 +6,7 @@
#include "raylib.h"
#include "utils.h" // AudioSignal, SignalStats
#include "primitives.h" // ScopeView, WaveformData
#include "mlnl.h" // MlnlAnnotations
#include <stdbool.h>
#include <math.h>
@@ -233,6 +234,47 @@ typedef struct {
bool isDividing; // True while user is dragging the divider
Vector2 dividerStartPos; // Mouse position when started dividing
float dividerStartY; // Spectro height when started dividing
// Display-side frequency crop. Caps the displayed frequency axis at this
// Hz value — purely a visualization concern (signal data, STFT, audio
// playback are unaffected). 0 = no crop, use full Nyquist. The crop is
// automatically clamped to the current signal's Nyquist by the helper
// below, so a 3 kHz crop is harmless when loading a 2 kHz-sample file.
// Persists across loads so a user analyzing mLink (≤3 kHz) doesn't have
// to re-set it after every file open.
float displayMaxFreqHz;
// True when a fresh signal has loaded and is waiting for ApplyAutoCrop to
// run (after the STFT exists, so the energy fallback can compute). Set by
// ResetForNewSignal; cleared by the loadingPhase=2 hook once autocrop has
// run, so an FFT-size change (same loadingPhase path) won't retrigger.
bool autocropPending;
// Notice splash shown when ApplyAutoCrop actually shrank the view. Modal:
// user dismisses with "OK" (keep crop) or "Uncrop" (restore full view).
bool autocropNoticeActive;
char autocropNoticeMsg[256];
// Optional mLnL annotations parsed from the loaded WAV (empty if the file
// doesn't carry the chunk). The annotations overlay has two surfaces:
// 1. A faint always-on draw on the spectrogram (alpha = opacityBase).
// 2. A "timeline lane" above the spectrogram for browsing events;
// hover/click in the lane bumps the matching spectrogram overlay to
// opacityHover so the user can find/inspect specific events without
// the overlay drowning the underlying signal.
MlnlAnnotations annotations;
int hoveredEvent; // spectrogram-cursor hit (-1 = none); used for tooltip
bool showAnnotations; // master on/off
bool annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.)
bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces)
float annotationOpacityBase; // 0..1 — quiet always-on alpha for spectrogram overlay
float annotationOpacityHover; // 0..1 — alpha for hovered/selected events
// Timeline lane state. The lane is rendered between the freq-range banner
// and the spectrogram pixels. Collapsed = single-row sparkline; expanded =
// one row per kind currently enabled in the file.
bool timelineExpanded;
int hoveredTimelineEvent; // -1 = none; event index hovered in the lane
int selectedAnnotation; // -1 = none; persistent selection from a lane click
} SpectrogramApp;
// ============================================================================
@@ -248,11 +290,21 @@ extern Font mainFont;
// (defined in spectrogram.c; used by every load path).
void ResetForNewSignal(void);
// Auto-crop the display freq axis and time view to "frequencies/times of
// interest" using whichever source has high confidence:
// 1) mLnL annotations — max(f_hi)+headroom for freq, span(t_start..t_end)+pad for time
// 2) STFT energy heuristic — cumulative-energy threshold for freq, activity envelope for time
// A no-op when neither source meets the confidence test (e.g. signal genuinely
// uses most of the band/timeline). Modifies displayMaxFreqHz, view.start/end,
// and invalidates the texture cache. Called automatically after STFT init on
// every file load; can also be re-run from the sidebar button.
void ApplyAutoCrop(void);
// True when a modal overlay owns input; normal spectrogram/keyboard interaction
// is gated off while this is the case. Add new overlays here in one place.
static inline bool UiModalOpen(void)
{
return app.showFileBrowser || app.showAbout;
return app.showFileBrowser || app.showAbout || app.autocropNoticeActive;
}
// Reset the box selection to the full signal (the "no selection" state).
@@ -262,6 +314,31 @@ static inline void ClearSelection(void)
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
}
// Effective top of the displayed frequency axis (Hz). Capped at the actual
// signal Nyquist so the crop never tries to show frequencies that aren't in
// the data. All DISPLAY-side code paths (labels, banner, annotation freq
// mapping, texture sampling fraction) should reach the frequency axis through
// this helper instead of computing sampleRate*0.5 directly. Data-side math
// (STFT bin spacing, PSD, audio filtering) keeps using the true Nyquist.
static inline float EffectiveMaxFreqHz(void)
{
if (app.signal.sampleRate <= 0) return 1.0f;
float nyq = app.signal.sampleRate * 0.5f;
if (app.displayMaxFreqHz > 0.0f && app.displayMaxFreqHz < nyq) return app.displayMaxFreqHz;
return nyq;
}
// Fraction of the texture's full frequency axis that should be visible (0..1).
// Used by the spectrogram's texture sub-image extraction: a value of 0.5 means
// "the visible window's freqEnd=1.0 corresponds to the texture's mid-row".
static inline float DisplayFreqFraction(void)
{
if (app.signal.sampleRate <= 0) return 1.0f;
float nyq = app.signal.sampleRate * 0.5f;
if (nyq <= 0.0f) return 1.0f;
return EffectiveMaxFreqHz() / nyq;
}
// ============================================================================
// Keymap — single source of truth for global key bindings.
// The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose
+168
View File
@@ -126,6 +126,7 @@ static void LoadSelectedFile(void)
NavigateToDirectory(app.browserFiles[app.browserSelected]);
} else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) {
ResetForNewSignal();
LoadMlnlFromWav(filePath, &app.annotations);
app.showFileBrowser = false;
TraceLog(LOG_INFO, "Loaded: %s", filePath);
}
@@ -427,6 +428,36 @@ void DrawSidebar(void)
DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;
// Display freq crop — only meaningful when a signal is loaded.
if (app.loaded && app.signal.sampleRate > 0) {
float nyq = app.signal.sampleRate * 0.5f;
float curr = EffectiveMaxFreqHz();
DrawTextScaled(TextFormat("Display max: %.0f Hz", curr), x, y, 13, LIGHTGRAY);
// "Auto" button to re-run the auto-crop heuristic (annotations first,
// then energy). Same logic that runs once on file load.
Rectangle autoBtn = { x + sidebarWidth - 58 * scale, y - 2 * scale,
48 * scale, 16 * scale };
if (Clicked(autoBtn)) ApplyAutoCrop();
DrawPanelBox(autoBtn, (Color){ 60, 50, 80, 255 }, (Color){ 160, 130, 200, 255 });
DrawTextScaled("auto", autoBtn.x + 12 * scale, autoBtn.y + 2 * scale, 10, WHITE);
y += 18 * scale;
Rectangle cropSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };
float frac = curr / nyq;
DrawSlider(cropSlider, frac);
if (UpdateSlider(cropSlider, &frac)) {
// Don't let the user crop to a useless sliver — keep at least
// ~2% of the band visible (250 Hz on a 12 kHz file).
if (frac < 0.02f) frac = 0.02f;
app.displayMaxFreqHz = frac * nyq;
// Reset view to show the full cropped range — otherwise a prior
// zoom-into-half-band would magnify even further after a crop.
app.view.freqStart = 0.0f;
app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
}
y += 22 * scale;
}
// Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel.
Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
if (Clicked(markerBtn)) app.markerMode = !app.markerMode;
@@ -444,6 +475,71 @@ void DrawSidebar(void)
specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE);
y += 30 * scale;
// ---- mLnL annotations panel — only when the loaded file has any ----
if (app.annotations.loaded && app.annotations.eventCount > 0) {
// Left half = master toggle. Right edge = small expand chevron that
// reveals per-kind checkboxes. The chevron is also clickable when the
// master toggle is OFF so the user can configure what to show before
// re-enabling overlays.
Rectangle annBtn = { x, y, (sidebarWidth - 10 * scale) - 28 * scale, 24 * scale };
Rectangle annExp = { annBtn.x + annBtn.width + 2 * scale, y, 26 * scale, 24 * scale };
if (Clicked(annBtn)) app.showAnnotations = !app.showAnnotations;
if (Clicked(annExp)) app.annotationsExpanded = !app.annotationsExpanded;
DrawPanelBox(annBtn,
app.showAnnotations ? (Color){ 70, 40, 90, 255 } : (Color){ 50, 50, 60, 255 },
app.showAnnotations ? (Color){ 200, 160, 255, 255 } : GRAY);
DrawTextScaled(app.showAnnotations ? "Annotations: ON" : "Annotations: off",
annBtn.x + 10 * scale, annBtn.y + 5 * scale, 13, WHITE);
DrawPanelBox(annExp, (Color){ 50, 50, 60, 255 }, GRAY);
DrawTextScaled(app.annotationsExpanded ? "v" : ">",
annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE);
y += 28 * scale;
if (app.annotationsExpanded) {
// Two opacity sliders: the spectrogram overlay is drawn at the
// "Base" alpha by default, and bumps to "Highlight" for any event
// that's hovered or selected in the timeline lane. Outlines and
// labels scale together with the fill so the whole overlay fades
// as one unit.
DrawTextScaled(TextFormat("Base: %d%%", (int)(app.annotationOpacityBase * 100.0f)),
x + 8 * scale, y, 12, LIGHTGRAY);
y += 16 * scale;
Rectangle baseSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
DrawSlider(baseSlider, app.annotationOpacityBase);
UpdateSlider(baseSlider, &app.annotationOpacityBase);
y += 20 * scale;
DrawTextScaled(TextFormat("Highlight: %d%%", (int)(app.annotationOpacityHover * 100.0f)),
x + 8 * scale, y, 12, LIGHTGRAY);
y += 16 * scale;
Rectangle hovSlider = { x + 8 * scale, y, sidebarWidth - 26 * scale, 12 * scale };
DrawSlider(hovSlider, app.annotationOpacityHover);
UpdateSlider(hovSlider, &app.annotationOpacityHover);
y += 22 * scale;
// Indented checkbox per kind actually present in this file. Order:
// walk the enum so the menu order is stable across files.
for (int k = 0; k < MLNL_KIND_MAX; k++) {
if (!app.annotations.kindPresent[k]) continue;
Rectangle cb = { x + 8 * scale, y + 2 * scale, 14 * scale, 14 * scale };
if (Clicked(cb)) app.annotationKindEnabled[k] = !app.annotationKindEnabled[k];
DrawPanelBox(cb, app.annotationKindEnabled[k] ? (Color){ 140, 100, 200, 255 } : DARKGRAY,
LIGHTGRAY);
DrawTextScaled(MlnlKindName((MlnlKind)k),
cb.x + 22 * scale, cb.y - 1 * scale, 12, LIGHTGRAY);
y += 18 * scale;
}
y += 4 * scale;
}
if (app.annotations.truncated) {
DrawTextScaled("(file truncated)", x + 4 * scale, y, 11,
(Color){ 255, 180, 180, 255 });
y += 16 * scale;
}
y += 6 * scale;
}
// File loading
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
@@ -649,3 +745,75 @@ void DrawAboutDialog(void)
// Note: opening/closing is handled in the main input loop (not here) so the
// same click/keypress that opens the dialog can't immediately close it.
}
// ===== Auto-crop notice splash =====
//
// Raised by ApplyAutoCrop when the view actually shrank. Modal: routes
// through UiModalOpen() so the spectrogram doesn't accept clicks underneath.
// Two buttons:
// [Uncrop] — restore full freq axis and full-duration view
// [OK] — dismiss; keep the cropped view
// Esc closes (= OK). Clicks outside the panel do nothing (avoids losing the
// crop by missing a button by a few px).
void DrawAutocropNotice(void)
{
if (!app.autocropNoticeActive) return;
float scale = GetUIScale();
int sw = GetScreenWidth();
int sh = GetScreenHeight();
DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.55f));
float pw = 460 * scale;
float ph = 170 * scale;
Rectangle panel = { (sw - pw) * 0.5f, (sh - ph) * 0.5f, pw, ph };
DrawRectangleRec(panel, (Color){ 30, 30, 40, 255 });
DrawRectangleLinesEx(panel, 2, (Color){ 160, 130, 200, 255 });
DrawTextScaled("Auto-crop applied",
panel.x + 20 * scale, panel.y + 16 * scale, 16,
(Color){ 200, 170, 240, 255 });
// Body: word-wrap not needed for the short message ApplyAutoCrop builds.
DrawTextScaled(app.autocropNoticeMsg,
panel.x + 20 * scale, panel.y + 50 * scale, 13, LIGHTGRAY);
float btnW = 110 * scale, btnH = 32 * scale;
float btnY = panel.y + ph - btnH - 16 * scale;
Rectangle uncropBtn = { panel.x + 20 * scale, btnY, btnW, btnH };
Rectangle okBtn = { panel.x + pw - btnW - 20*scale, btnY, btnW, btnH };
bool uncropHover = CheckCollisionPointRec(GetMousePosition(), uncropBtn);
bool okHover = CheckCollisionPointRec(GetMousePosition(), okBtn);
DrawPanelBox(uncropBtn,
uncropHover ? (Color){ 100, 60, 60, 255 } : (Color){ 70, 40, 40, 255 },
(Color){ 230, 160, 160, 255 });
DrawTextScaled("Uncrop",
uncropBtn.x + btnW * 0.5f - MeasureTextScaled("Uncrop", 14) * 0.5f,
uncropBtn.y + 8 * scale, 14, WHITE);
DrawPanelBox(okBtn,
okHover ? (Color){ 60, 90, 60, 255 } : (Color){ 40, 70, 40, 255 },
(Color){ 160, 220, 160, 255 });
DrawTextScaled("OK",
okBtn.x + btnW * 0.5f - MeasureTextScaled("OK", 14) * 0.5f,
okBtn.y + 8 * scale, 14, WHITE);
DrawTextScaled("Esc or Enter = OK", panel.x + pw - 156 * scale,
panel.y + ph - 14 * scale, 10, GRAY);
// Hit handling. Esc/Enter dismiss (keep crop). Uncrop restores full view.
if (IsKeyPressed(KEY_ESCAPE) || IsKeyPressed(KEY_ENTER)) {
app.autocropNoticeActive = false;
}
if (uncropHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.displayMaxFreqHz = 0.0f;
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
app.autocropNoticeActive = false;
} else if (okHover && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.autocropNoticeActive = false;
}
}
+5
View File
@@ -18,4 +18,9 @@ void ExportPNG(const SpectrogramApp* spa, const char* dirPath);
// --- About / help dialog ---
void DrawAboutDialog(void);
// --- Auto-crop notice splash ---
// Shown right after ApplyAutoCrop changed the view; OK keeps the crop,
// Uncrop restores the full freq + time view.
void DrawAutocropNotice(void);
#endif // UI_H