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:
+387
@@ -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
@@ -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
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user