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

Commit the accumulated working-tree changes as one snapshot.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:19:37 -07:00
parent cef7619833
commit ac262505c1
17 changed files with 2263 additions and 257 deletions
+751 -7
View File
@@ -293,8 +293,9 @@ void DrawLabels(Rectangle bounds)
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
}
// Frequency labels adapted to current zoom level
float maxFreq = (float)app.signal.sampleRate / 2.0f;
// Frequency labels adapted to current zoom level. Honors the display crop:
// 1.0 of the view range is the user's chosen max, not raw Nyquist.
float maxFreq = EffectiveMaxFreqHz();
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
@@ -503,8 +504,12 @@ void DrawCursorReadout(Rectangle bounds)
float tFrac = app.view.start + ((m.x - bounds.x) / bounds.width) * (app.view.end - app.view.start);
float fFrac = app.view.freqStart + (1.0f - (m.y - bounds.y) / bounds.height) * (app.view.freqEnd - app.view.freqStart);
float timeSec = tFrac * app.signal.duration;
float nyquist = app.signal.sampleRate * 0.5f;
float freqHz = fFrac * nyquist;
// Map cursor freq through the cropped axis (so 100% of view = chosen max,
// not raw Nyquist), but use the true Nyquist for STFT bin spacing — the
// bins themselves still cover the full signal regardless of the crop.
float displayMax = EffectiveMaxFreqHz();
float dataNyquist = app.signal.sampleRate * 0.5f;
float freqHz = fFrac * displayMax;
// Sample the STFT level at this (time, freq).
char level[32] = "--";
@@ -514,7 +519,7 @@ void DrawCursorReadout(Rectangle bounds)
if (seg >= app.stft.numSegments) seg = app.stft.numSegments - 1;
const StftSegment* s = &app.stft.segments[seg];
if (s->spectrum && s->numBins > 1) {
float binHz = nyquist / (float)(s->numBins - 1);
float binHz = dataNyquist / (float)(s->numBins - 1);
int bin = (int)(freqHz / binHz + 0.5f);
if (bin < 0) bin = 0;
if (bin >= s->numBins) bin = s->numBins - 1;
@@ -581,8 +586,10 @@ void DrawMarkers(Rectangle bounds)
if (bIn) DrawMarkerCross(b, (Color){ 255, 180, 80, 255 }, "B");
EndScissorMode();
// Compute deltas in real units.
float nyquist = app.signal.sampleRate * 0.5f;
// Compute deltas in real units. Marker positions are normalized inside
// the displayed view, so they scale with the crop just like the freq
// axis labels do — what the user sees IS what they measure.
float nyquist = EffectiveMaxFreqHz();
float ta = app.marker.t0 * app.signal.duration, tb = app.marker.t1 * app.signal.duration;
float fa = app.marker.f0 * nyquist, fb = app.marker.f1 * nyquist;
float dt = fabsf(tb - ta);
@@ -722,6 +729,743 @@ void DrawSpectrumPanel(Rectangle bounds)
free(power);
}
// ============================================================================
// mLnL annotation overlay (schema v2)
//
// Layer order (per the spec; deepest first):
// 1. tx_frame — PRIMARY: filled translucent box + outline + per-frame label,
// each frame drawn in its own frequency lane (announce / bulk / emergency)
// 2. assertion_passed / assertion_failed — thin outlined rectangles
// 3. control / channel_up / channel_down / impairment / gain — vertical lines
// Each pass also records the topmost hover hit so the tooltip drawn last
// reflects the visually-frontmost annotation under the cursor.
// ============================================================================
// Default per-node palette used when an event omits the `color` field.
static Color NodeColor(unsigned int node)
{
static const Color palette[] = {
{ 120, 220, 255, 255 }, // sky
{ 255, 180, 80, 255 }, // amber
{ 160, 255, 140, 255 }, // mint
{ 255, 130, 200, 255 }, // pink
{ 200, 160, 255, 255 }, // lavender
{ 255, 230, 110, 255 }, // pale gold
};
return palette[node % (sizeof(palette) / sizeof(palette[0]))];
}
// Resolve an event's display color: use the producer-supplied `color` field
// if present, otherwise fall back to a per-kind default (tx_burst uses node
// palette; assertions use spec defaults; controls/etc. get a neutral hint).
static Color EventColor(const MlnlEvent* e)
{
if (e->has_color) return (Color){ e->colorR, e->colorG, e->colorB, 255 };
switch (e->kind) {
case MLNL_KIND_TX_FRAME:
case MLNL_KIND_TX_BURST:
return NodeColor(e->has_node ? e->node : 0);
case MLNL_KIND_ASSERTION_PASSED: return (Color){ 60, 179, 113, 255 }; // #3CB371
case MLNL_KIND_ASSERTION_FAILED: return (Color){ 214, 40, 40, 255 }; // #D62828
case MLNL_KIND_CONTROL: return (Color){ 255, 220, 120, 255 };
default: return (Color){ 200, 200, 220, 255 };
}
}
// Map (t_s, freq_hz) to screen, honoring zoom. duration_s and nyquist_hz are
// the signal's extents.
static Vector2 AnnoToScreen(Rectangle bounds, double t_s, double f_hz,
double duration_s, double nyquist_hz)
{
double tFrac = (duration_s > 0.0) ? (t_s / duration_s) : 0.0;
double fFrac = (nyquist_hz > 0.0) ? (f_hz / nyquist_hz) : 0.0;
double vw = app.view.end - app.view.start;
double fw = app.view.freqEnd - app.view.freqStart;
Vector2 p;
p.x = bounds.x + (float)((tFrac - app.view.start) / vw) * bounds.width;
p.y = bounds.y + bounds.height - (float)((fFrac - app.view.freqStart) / fw) * bounds.height;
return p;
}
// Project the event's time+frequency band into a screen rectangle, clipped to
// the viewport. Events with no freq fields span the full frequency axis.
// Returns false if the result is entirely outside the visible area.
static bool EventRect(Rectangle bounds, const MlnlEvent* e,
double duration_s, double nyquist_hz, Rectangle* out)
{
double f0 = e->has_freq ? e->f_lo_hz : 0.0;
double f1 = e->has_freq ? e->f_hi_hz : nyquist_hz;
Vector2 lo = AnnoToScreen(bounds, e->t_start, f0, duration_s, nyquist_hz);
Vector2 hi = AnnoToScreen(bounds, e->t_end, f1, duration_s, nyquist_hz);
float x = fminf(lo.x, hi.x);
float y = fminf(lo.y, hi.y);
float w = fabsf(hi.x - lo.x);
float h = fabsf(hi.y - lo.y);
if (x + w < bounds.x || x > bounds.x + bounds.width) return false;
float x0 = fmaxf(x, bounds.x);
float x1 = fminf(x + w, bounds.x + bounds.width);
float y0 = fmaxf(y, bounds.y);
float y1 = fminf(y + h, bounds.y + bounds.height);
if (x1 <= x0 || y1 <= y0) return false;
*out = (Rectangle){ x0, y0, x1 - x0, y1 - y0 };
return true;
}
// Build the tooltip lines for an event. Fields are listed in priority order
// (kind banner, note, time range, freq band, then kind-specific extras).
// Format a tx_frame's intent->air latency compactly: "+160ms", "+4.0s".
// sched_offset_ms = air_time - modem intent time (how late the burst hit the
// air vs. when it was rendered); see mlnl_chunk_spec.md §4.2.
static void FormatSchedOffset(double ms, char* out, int cap)
{
const char* sign = (ms < 0) ? "-" : "+";
double a = fabs(ms);
if (a >= 1000.0) snprintf(out, cap, "%s%.1fs", sign, a / 1000.0);
else snprintf(out, cap, "%s%.0fms", sign, a);
}
static int BuildEventLines(const MlnlEvent* e, char lines[][96], int maxLines)
{
int n = 0;
if (n < maxLines) snprintf(lines[n++], 96, "%s", e->kindStr);
if (e->has_note && n < maxLines) snprintf(lines[n++], 96, "%s", e->note);
double dt = e->t_end - e->t_start;
if (dt > 0.0) {
if (n < maxLines) snprintf(lines[n++], 96, "%.3f - %.3f s", e->t_start, e->t_end);
if (n < maxLines) snprintf(lines[n++], 96, "dur: %.3f s", dt);
} else {
if (n < maxLines) snprintf(lines[n++], 96, "t: %.3f s", e->t_start);
}
if (e->has_freq && n < maxLines) snprintf(lines[n++], 96, "%.0f - %.0f Hz", e->f_lo_hz, e->f_hi_hz);
// tx_frame specifics: frame name, daemon channel, position in the PTT, rate.
if (e->has_frame && n < maxLines) snprintf(lines[n++], 96, "frame: %s", e->frame);
if (e->has_ch && n < maxLines) snprintf(lines[n++], 96, "ch: %s", e->ch);
if (e->has_seqn && e->nFrames > 1 && n < maxLines)
snprintf(lines[n++], 96, "frame %d of %d", e->seq + 1, e->nFrames);
if (e->has_rate && n < maxLines) snprintf(lines[n++], 96, "rate: %s", e->rate);
// Intent->air latency: how late this burst hit the air vs. the modem's
// render time. Boxes are already air-anchored upstream, so this is purely
// informational (see mlnl_chunk_spec.md §4.2).
if (e->has_sched_offset && n < maxLines) {
char so[24];
FormatSchedOffset(e->sched_offset_ms, so, sizeof(so));
snprintf(lines[n++], 96, "sched offset: %s", so);
}
if (e->has_node && n < maxLines) {
// For tx_burst prefer "node N <LABEL> #id" so the operator can map back
// to the harness log even when note is identical between siblings.
if (e->has_label && e->has_id)
snprintf(lines[n++], 96, "node %u %s #%u", e->node, e->label, e->id);
else if (e->has_id)
snprintf(lines[n++], 96, "node %u #%u", e->node, e->id);
else
snprintf(lines[n++], 96, "node: %u", e->node);
} else if (e->has_id && n < maxLines) {
snprintf(lines[n++], 96, "#%u", e->id);
}
if (e->has_command && n < maxLines) snprintf(lines[n++], 96, "cmd: %s", e->command);
if (e->has_name && n < maxLines) snprintf(lines[n++], 96, "%.90s", e->name);
if (e->has_reason && n < maxLines) snprintf(lines[n++], 96, "reason: %.80s", e->reason);
if (e->has_slack && n < maxLines) snprintf(lines[n++], 96, "slack: %.2fs", e->slack_s);
if (e->has_stats) {
if (n < maxLines) snprintf(lines[n++], 96, "peak: %.3f", e->peak);
if (n < maxLines) snprintf(lines[n++], 96, "rms: %.3f", e->rms);
if (n < maxLines) snprintf(lines[n++], 96, "PAPR: %.1f dB", e->papr_db);
}
return n;
}
// Draw a label inside (or just above) `box` in `color`, scissor-clipped to
// the box's horizontal extent so long notes don't bleed over neighbors.
// topInside=true places the label inside the top of the box (used for tx_bursts
// so the box outline still reads clearly above); false places it just above,
// falling back to inside if the box is at the top of the viewport.
static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topInside)
{
if (!text || !*text || box.width < 18.0f) return;
float scale = GetUIScale();
float fs = 10.0f;
float lineH = fs * scale + 2;
int x = (int)box.x + 3;
int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH);
if (y < 0) y = (int)box.y + 2;
BeginScissorMode(x, y, (int)box.width - 4, (int)lineH);
DrawTextScaled(text, x, y, fs, color);
EndScissorMode();
}
static void DrawTooltip(Rectangle bounds, Vector2 anchor,
char lines[][96], int n, Color border)
{
if (n <= 0) return;
float scale = GetUIScale();
float fontSize = 11.0f;
float lineH = fontSize * scale + 4;
float padX = 10 * scale;
float padY = 6 * scale;
float maxW = 0;
for (int i = 0; i < n; i++) {
float w = MeasureTextScaled(lines[i], fontSize);
if (w > maxW) maxW = w;
}
int boxW = (int)(maxW + padX * 2);
int boxH = (int)(n * lineH + padY * 2);
float bx = anchor.x + 12, by = anchor.y + 12;
if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12;
if (bx < bounds.x) bx = bounds.x;
if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH;
if (by < bounds.y) by = bounds.y;
DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 220 });
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(border, 0.8f));
for (int i = 0; i < n; i++)
DrawTextScaled(lines[i], bx + padX, by + padY + i * lineH, fontSize, LIGHTGRAY);
}
static bool IsPointEvent(const MlnlEvent* e)
{
if (e->t_end > e->t_start) return false; // has a range -> draw as rect
switch (e->kind) {
case MLNL_KIND_CONTROL:
case MLNL_KIND_CHANNEL_UP:
case MLNL_KIND_CHANNEL_DOWN:
case MLNL_KIND_IMPAIRMENT_FIRE:
case MLNL_KIND_GAIN_CHANGE:
case MLNL_KIND_UNKNOWN:
return true;
default:
return true; // zero-width assertion etc. also render as vertical line
}
}
// True iff the given event is currently emphasized (selected, or hovered in
// the timeline lane). Spectrogram-cursor hover is intentionally NOT emphasized
// — otherwise simply moving the mouse over the viewport would flicker the
// overlays. Emphasis is reserved for explicit indication via the timeline.
static bool AnnotationEmphasized(int eventIndex)
{
return (app.selectedAnnotation == eventIndex) ||
(app.hoveredTimelineEvent == eventIndex);
}
static unsigned char AlphaForEvent(int eventIndex, float fillMultiplier)
{
float op = AnnotationEmphasized(eventIndex)
? app.annotationOpacityHover
: app.annotationOpacityBase;
return (unsigned char)Clamp(op * fillMultiplier, 0.0f, 255.0f);
}
// Compose a tx_frame's in-box label per spec §5: the frame name, its LDPC rate
// (bulk frames only), then its position in the PTT ("seq of n"). e.g.
// "BULK 3/4 3/6" or "PRESENCE/SEED". Falls back to the producer note.
static void FormatTxFrameLabel(const MlnlEvent* e, char* out, int cap)
{
char pos[16] = { 0 };
if (e->has_seqn && e->nFrames > 1)
snprintf(pos, sizeof(pos), " %d/%d", e->seq + 1, e->nFrames);
const char* base = e->has_frame ? e->frame : (e->has_note ? e->note : "tx_frame");
if (e->has_frame && e->has_rate)
snprintf(out, cap, "%s %s%s", base, e->rate, pos);
else
snprintf(out, cap, "%s%s", base, pos);
}
void DrawAnnotations(Rectangle bounds)
{
if (!app.loaded || !app.annotations.loaded) return;
if (!app.showAnnotations) { app.hoveredEvent = -1; return; }
if (app.signal.duration <= 0.0f) return;
app.hoveredEvent = -1;
double duration = app.signal.duration;
// Annotation freq mapping uses the DISPLAYED top-of-axis: events with
// f_lo/f_hi above the crop simply get clipped at the top of the visible
// area, the same way pan/zoom already handles off-screen events.
double nyquist = EffectiveMaxFreqHz();
Vector2 m = GetMousePosition();
bool mouseInBounds = CheckCollisionPointRec(m, bounds);
bool suppressHover = app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
app.sel.isDragging || app.view.isPanning || app.marker.dragging ||
app.markerMode;
int hoverEvent = -1;
// hover prefers later layers (assertions over bursts, point markers over both),
// which matches the spec's draw-order rationale: things on top win the click.
// ---- Layer 1: tx_burst (wash + outline) ----
// tx_burst uses the full 200 fill multiplier; emphasis comes from a higher
// per-event opacity, NOT a different multiplier.
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_BURST) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
unsigned char fillA = AlphaForEvent(i, 200.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1.5f, stroke);
DrawBoxLabel(r, e->has_note ? e->note : e->kindStr, stroke, /*topInside=*/true);
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
hoverEvent = i;
}
// ---- Layer 1b: tx_frame (PRIMARY per-frame fill + outline + label) ----
// The daemon's own per-frame self-report. Each frame carries its exact band
// (f_lo/f_hi resolved from its `ch`), so one transmission renders as a
// readable sequence of boxes laid out across the announce / bulk lanes.
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_FRAME) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
unsigned char fillA = AlphaForEvent(i, 200.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1.5f, stroke);
char lbl[64];
FormatTxFrameLabel(e, lbl, sizeof(lbl));
DrawBoxLabel(r, lbl, stroke, /*topInside=*/true);
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
hoverEvent = i;
}
// ---- Layer 2: assertion_passed / assertion_failed (outline-dominant; a
// very light wash so failed regions still tint red without blanking the
// signal underneath) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (e->t_end <= e->t_start) continue;
Rectangle r;
if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
unsigned char fillA = AlphaForEvent(i, 100.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1.0f, stroke);
DrawBoxLabel(r, e->has_note ? e->note : (e->has_name ? e->name : e->kindStr),
stroke, /*topInside=*/false);
if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r))
hoverEvent = i;
}
// ---- Layer 3: point markers (vertical lines for zero-width events) ----
float labelStaggerY = 0.0f;
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (!IsPointEvent(e)) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double tFrac = e->t_start / duration;
if (tFrac < app.view.start || tFrac > app.view.end) continue;
float x = bounds.x + (float)((tFrac - app.view.start) /
(app.view.end - app.view.start)) * bounds.width;
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
Color stroke = (Color){ c.r, c.g, c.b, strokeA };
DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height), Fade(stroke, 0.6f));
DrawCircleLines((int)x, (int)bounds.y + 6, 3, stroke);
const char* lbl = e->has_note ? e->note :
e->has_command ? e->command :
e->has_name ? e->name : e->kindStr;
float lblScale = GetUIScale();
float lblFs = 10.0f;
float lblLineH = lblFs * lblScale + 2;
int labelY = (int)(bounds.y + 14 * lblScale + labelStaggerY);
int labelMaxW = (int)(bounds.x + bounds.width) - ((int)x + 4);
if (labelMaxW > 8) {
BeginScissorMode((int)x + 4, labelY, labelMaxW, (int)lblLineH);
DrawTextScaled(lbl, (int)x + 4, labelY, lblFs, stroke);
EndScissorMode();
}
labelStaggerY = (labelStaggerY > 24.0f * lblScale) ? 0.0f : labelStaggerY + lblLineH;
if (!suppressHover && mouseInBounds &&
m.x >= x - 4 && m.x <= x + 4 &&
m.y >= bounds.y && m.y <= bounds.y + bounds.height) {
hoverEvent = i;
}
}
app.hoveredEvent = hoverEvent;
// ---- Tooltip ---- Timeline hover takes priority over spectrogram hover
// (the lane is the active surface when you're hovering it).
int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent;
if (tipFor >= 0) {
const MlnlEvent* e = &app.annotations.events[tipFor];
char lines[12][96];
int n = BuildEventLines(e, lines, 12);
DrawTooltip(bounds, m, lines, n, EventColor(e));
}
if (app.annotations.truncated) {
const char* msg = "mLnL: truncated";
float fs = 10.0f;
float w = MeasureTextScaled(msg, fs);
float h = fs * GetUIScale() + 6;
DrawRectangle((int)(bounds.x + bounds.width - w - 14), (int)(bounds.y + 4),
(int)(w + 10), (int)h, (Color){ 60, 0, 0, 200 });
DrawTextScaled(msg, bounds.x + bounds.width - w - 9, bounds.y + 7,
fs, (Color){ 255, 200, 200, 255 });
}
}
// ============================================================================
// Annotation overlay on the waveform scope (time-axis only)
// ============================================================================
//
// The scope has no frequency axis so annotations render as full-height time
// bands (tx_burst + assertion) or vertical lines (point events). No hit-test:
// hover/select still happens via the timeline lane, this is purely a visual
// echo so the user can correlate the spectrogram and waveform views.
//
// Reuses AlphaForEvent / EventColor / IsPointEvent so the spectrogram and
// scope move together — selecting an event in the timeline lights it on both.
void DrawAnnotationsOnScope(Rectangle bounds)
{
if (!app.loaded || !app.annotations.loaded) return;
if (!app.showAnnotations) return;
if (app.signal.duration <= 0.0f) return;
if (bounds.width < 4 || bounds.height < 4) return;
double duration = app.signal.duration;
double viewW = app.view.end - app.view.start;
if (viewW <= 0.0) return;
// Map a time fraction (0..1) to a screen x within the scope bounds.
#define SC_X(tFrac) (bounds.x + (float)(((tFrac) - app.view.start) / viewW) * bounds.width)
// ---- Layer 1: tx_burst (full-height band) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_BURST) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (x0 < bounds.x) x0 = bounds.x;
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
if (x1 - x0 < 1) continue;
Color c = EventColor(e);
unsigned char fillA = AlphaForEvent(i, 200.0f);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
(Color){ c.r, c.g, c.b, fillA });
// Hairlines top & bottom so the band reads as deliberate framing
// rather than tinted background, even at low alpha.
DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
(Color){ c.r, c.g, c.b, strokeA });
DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
(int)x1, (int)(bounds.y + bounds.height - 1),
(Color){ c.r, c.g, c.b, strokeA });
}
// ---- Layer 1b: tx_frame (full-height band) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_TX_FRAME) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (x0 < bounds.x) x0 = bounds.x;
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
if (x1 - x0 < 1) continue;
Color c = EventColor(e);
unsigned char fillA = AlphaForEvent(i, 200.0f);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
(Color){ c.r, c.g, c.b, fillA });
DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
(Color){ c.r, c.g, c.b, strokeA });
DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
(int)x1, (int)(bounds.y + bounds.height - 1),
(Color){ c.r, c.g, c.b, strokeA });
}
// ---- Layer 2: assertion outlines (full-height) ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (e->t_end <= e->t_start) continue;
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (x0 < bounds.x) x0 = bounds.x;
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
if (x1 - x0 < 1) continue;
Color c = EventColor(e);
unsigned char fillA = AlphaForEvent(i, 100.0f);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
Rectangle r = { x0, bounds.y, x1 - x0, bounds.height };
DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
DrawRectangleLinesEx(r, 1, (Color){ c.r, c.g, c.b, strokeA });
}
// ---- Layer 3: point events as vertical lines ----
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (!IsPointEvent(e)) continue;
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
double t0f = e->t_start / duration;
if (t0f < app.view.start || t0f > app.view.end) continue;
float x = SC_X(t0f);
Color c = EventColor(e);
unsigned char strokeA = AlphaForEvent(i, 255.0f);
DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height),
Fade((Color){ c.r, c.g, c.b, strokeA }, 0.6f));
}
// ---- Hover hit-test + tooltip ----
// The scope only carries a time axis, so a hit is purely horizontal: the
// mouse x falls inside an event's band (range events) or near its line
// (point events). Narrowest match wins so a short event inside a wide band
// is still reachable. Pops the same detail tooltip as the spectrogram, so
// the sched-offset / metadata is available from either view.
Vector2 m = GetMousePosition();
bool suppress = app.sel.isDragging || app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
app.view.isPanning || app.marker.dragging;
if (!suppress && CheckCollisionPointRec(m, bounds)) {
int hover = -1;
double bestW = 1e18;
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
if (IsPointEvent(e)) {
double t0f = e->t_start / duration;
if (t0f < app.view.start || t0f > app.view.end) continue;
float x = SC_X(t0f);
if (m.x >= x - 4 && m.x <= x + 4) { bestW = 0.0; hover = i; }
} else if (e->kind == MLNL_KIND_TX_BURST || e->kind == MLNL_KIND_TX_FRAME ||
e->kind == MLNL_KIND_ASSERTION_PASSED || e->kind == MLNL_KIND_ASSERTION_FAILED) {
double t0f = e->t_start / duration, t1f = e->t_end / duration;
if (t1f < app.view.start || t0f > app.view.end) continue;
float x0 = SC_X(t0f), x1 = SC_X(t1f);
if (m.x >= x0 && m.x <= x1) {
double w = t1f - t0f;
if (w <= bestW) { bestW = w; hover = i; }
}
}
}
if (hover >= 0) {
const MlnlEvent* e = &app.annotations.events[hover];
char lines[12][96];
int n = BuildEventLines(e, lines, 12);
DrawTooltip(bounds, m, lines, n, EventColor(e));
}
}
#undef SC_X
}
// ============================================================================
// Timeline lane
// ============================================================================
// Count enabled-and-present annotation kinds. Mirrors the helper in
// spectrogram.c — kept local because static functions don't cross translation
// units; if it grows beyond two callers move it into a shared module.
static int CountVisibleAnnotationKindsLocal(void)
{
int n = 0;
for (int k = 0; k < MLNL_KIND_MAX; k++)
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
return n;
}
// Sort items by event duration descending: widest first, narrowest last.
// We draw in that order so the narrowest event ends up on top, and our
// hit-test (which keeps the *last* hit) naturally picks the narrowest event
// under the cursor — exactly what the user asked for so big chunks can't
// wash out short events sitting inside them.
typedef struct { int idx; double dur; } TlSortItem;
static int CmpTlSortDesc(const void* a, const void* b)
{
double da = ((const TlSortItem*)a)->dur, db = ((const TlSortItem*)b)->dur;
if (da > db) return -1;
if (da < db) return 1;
return 0;
}
// Row index (0..numRows-1) for a kind in the expanded timeline. -1 if hidden.
static int TimelineRowForKind(int kind)
{
if (kind < 0 || kind >= MLNL_KIND_MAX) return -1;
if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) return -1;
int r = 0;
for (int k = 0; k < kind; k++)
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) r++;
return r;
}
void DrawTimeline(Rectangle lane)
{
if (!app.annotations.loaded || !app.showAnnotations) return;
if (app.annotations.eventCount == 0) return;
if (lane.width < 8 || lane.height < 4) return;
// Background. Slightly different from the spectrogram so the lane reads
// as a separate UI surface rather than blending into the viewport.
DrawRectangleRec(lane, (Color){ 8, 10, 14, 235 });
DrawRectangleLinesEx(lane, 1, Fade(GRAY, 0.35f));
double duration = app.signal.duration;
if (duration <= 0) return;
// Reserve a narrow chevron strip on the right for the expand/collapse toggle.
float chevW = 14.0f * GetUIScale();
Rectangle chev = { lane.x + lane.width - chevW - 2, lane.y, chevW, lane.height };
Rectangle plot = { lane.x + 2, lane.y + 1, lane.width - chevW - 6, lane.height - 2 };
Vector2 m = GetMousePosition();
bool mouseInLane = CheckCollisionPointRec(m, lane);
bool mouseInPlot = CheckCollisionPointRec(m, plot);
bool mouseInChev = CheckCollisionPointRec(m, chev);
app.hoveredTimelineEvent = -1;
// Build a list of visible event indices (those not filtered by the
// per-kind checkboxes) sorted by duration descending so we draw widest
// first / narrowest last (last hit wins on hover).
int eventCount = app.annotations.eventCount;
TlSortItem* items = (TlSortItem*)malloc((size_t)eventCount * sizeof(TlSortItem));
if (!items) return;
int nVisible = 0;
for (int i = 0; i < eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
items[nVisible].idx = i;
items[nVisible].dur = e->t_end - e->t_start;
nVisible++;
}
qsort(items, nVisible, sizeof(TlSortItem), CmpTlSortDesc);
// Helper: time (seconds) -> screen X within plot.
#define TL_X(t) (plot.x + (float)((t) / duration) * plot.width)
if (!app.timelineExpanded) {
// Single mixed strip. Each event is a colored rect spanning [t0,t1]
// (point events get a 2px tick). Narrowest-on-top is implicit in the
// sort order.
for (int k = 0; k < nVisible; k++) {
int i = items[k].idx;
const MlnlEvent* e = &app.annotations.events[i];
Color c = EventColor(e);
float x0 = TL_X(e->t_start);
float x1 = TL_X(e->t_end);
float w = x1 - x0;
if (w < 2.0f) w = 2.0f; // visibility minimum for narrow events
Rectangle r = { x0, plot.y, w, plot.height };
if (app.selectedAnnotation == i)
DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
DrawRectangleRec(r, c);
if (mouseInPlot && CheckCollisionPointRec(m, r))
app.hoveredTimelineEvent = i;
}
} else {
// One row per enabled kind. Draw row labels + separators first, then
// events on top (still narrowest-last).
int numRows = CountVisibleAnnotationKindsLocal();
if (numRows < 1) numRows = 1;
float rowH = plot.height / (float)numRows;
for (int kind = 0, row = 0; kind < MLNL_KIND_MAX; kind++) {
if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) continue;
float ry = plot.y + row * rowH;
if (row > 0)
DrawLine((int)plot.x, (int)ry, (int)(plot.x + plot.width), (int)ry, Fade(GRAY, 0.2f));
DrawTextScaled(MlnlKindName((MlnlKind)kind), plot.x + 4, ry + 1, 9,
Fade(LIGHTGRAY, 0.7f));
row++;
}
for (int k = 0; k < nVisible; k++) {
int i = items[k].idx;
const MlnlEvent* e = &app.annotations.events[i];
int row = TimelineRowForKind(e->kind);
if (row < 0) continue;
Color c = EventColor(e);
float x0 = TL_X(e->t_start);
float x1 = TL_X(e->t_end);
float w = x1 - x0;
if (w < 2.0f) w = 2.0f;
float ry = plot.y + row * rowH + 1;
float rh = rowH - 2;
Rectangle r = { x0, ry, w, rh };
if (app.selectedAnnotation == i)
DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
DrawRectangleRec(r, c);
if (mouseInPlot && CheckCollisionPointRec(m, r))
app.hoveredTimelineEvent = i;
}
}
free(items);
#undef TL_X
// Chevron toggle (separate hit area from the plot so it always responds).
if (mouseInChev) DrawRectangleRec(chev, Fade(GRAY, 0.25f));
float chevFs = 10.0f;
DrawTextScaled(app.timelineExpanded ? "v" : ">",
chev.x + 3, chev.y + chev.height * 0.5f - chevFs * GetUIScale() * 0.5f,
chevFs, LIGHTGRAY);
if (mouseInChev && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
app.timelineExpanded = !app.timelineExpanded;
// Click handling. In collapsed mode the lane is small + low-resolution,
// so any click in the plot expands rather than risking an accidental
// selection. Selection is an "expanded mode" gesture.
if (mouseInPlot && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
if (!app.timelineExpanded) {
app.timelineExpanded = true;
} else if (app.hoveredTimelineEvent >= 0) {
app.selectedAnnotation = (app.selectedAnnotation == app.hoveredTimelineEvent)
? -1 : app.hoveredTimelineEvent;
} else {
app.selectedAnnotation = -1;
}
}
// Subtle hint when hovering a collapsed lane (helps the user discover
// the expand affordance the first time they see annotations).
if (!app.timelineExpanded && mouseInLane) {
int kinds = CountVisibleAnnotationKindsLocal();
if (kinds > 0) {
char hint[48];
snprintf(hint, sizeof(hint), "%d kinds — click to expand", kinds);
float hintFs = 9.0f;
float w = MeasureTextScaled(hint, hintFs);
DrawTextScaled(hint, plot.x + plot.width - w - 4, plot.y - 1,
hintFs, Fade(LIGHTGRAY, 0.85f));
}
}
}
// ============================================================================
// Playhead
// ============================================================================