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