Files
rspektrum/src/render.c
T
tyler ac262505c1 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>
2026-05-29 12:19:37 -07:00

1490 lines
66 KiB
C

// render.c - colormaps, spectrogram texture generation, and on-screen drawing
#include "render.h"
#include "stft.h" // ComputeSpectralStats for the selection panel
#include "utils.h" // ComputeSignalStats
#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
// ===== UI scaling and scaled text =====
// Base resolution for proportional UI scaling.
// GetUIScale() uses logical screen (not framebuffer) dimensions so that
// layout stays based on window size alone. FLAG_WINDOW_HIGHDPI makes
// BeginDrawing() render to the framebuffer at the correct resolution, so
// every 1px drawn in layout coordinates automatically maps to the right
// physical size on any monitor.
float GetUIScale(void)
{
float scaleX = (float)GetScreenWidth() / BASE_WIDTH;
float scaleY = (float)GetScreenHeight() / BASE_HEIGHT;
return (scaleX + scaleY) / 2.0f;
}
void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color)
{
if (mainFont.texture.id == 0) {
// Fallback to default if font not loaded
DrawText(text, (int)x, (int)y, (int)baseSize, color);
return;
}
float scaledSize = baseSize * GetUIScale();
float spacing = scaledSize * 0.25f; // 25% of font size for spacing
DrawTextEx(mainFont, text, (Vector2){ x, y }, scaledSize, spacing, color);
}
float MeasureTextScaled(const char* text, float baseSize)
{
if (mainFont.texture.id == 0) return MeasureText(text, (int)baseSize);
float scaledSize = baseSize * GetUIScale();
float spacing = scaledSize * 0.25f;
return MeasureTextEx(mainFont, text, scaledSize, spacing).x;
}
// ===== Colormaps =====
// Each colormap maps t in [0,1] to a Color. To add a colormap: add an enum
// value in spectrogram_types.h, write a Cmap* function, and add one row to
// COLORMAPS[] below — the sidebar names and lookups follow automatically.
typedef Color (*ColormapFn)(float t);
static Color CmapGrays(float t)
{
unsigned char v = (unsigned char)(t * 255);
return (Color){ v, v, v, 255 };
}
static Color CmapInferno(float t)
{
float r = 0.0f, g = 0.0f, b = 0.0f;
if (t < 0.25f) { t = t / 0.25f; r = 0.0f + t * 0.5f; g = 0.0f; b = 0.0f + t * 0.3f; }
else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.5f + t * 0.5f; g = 0.0f + t * 0.3f; b = 0.3f + t * 0.4f; }
else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 1.0f; g = 0.3f + t * 0.5f; b = 0.7f + t * 0.2f; }
else { t = (t - 0.75f) / 0.25f; r = 1.0f; g = 0.8f + t * 0.2f; b = 0.9f + t * 0.1f; }
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}
static Color CmapViridis(float t)
{
float r, g, b;
if (t < 0.25f) { t = t / 0.25f; r = 0.27f + t * 0.13f; g = 0.00f + t * 0.33f; b = 0.33f + t * 0.27f; }
else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.40f + t * 0.16f; g = 0.33f + t * 0.29f; b = 0.60f - t * 0.20f; }
else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 0.56f + t * 0.24f; g = 0.62f + t * 0.23f; b = 0.40f - t * 0.20f; }
else { t = (t - 0.75f) / 0.25f; r = 0.80f + t * 0.17f; g = 0.85f + t * 0.12f; b = 0.20f - t * 0.15f; }
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}
static Color CmapPlasma(float t)
{
float r = 0.05f + t * 0.9f;
float g = 0.0f + t * 0.6f + (t > 0.5f ? (t - 0.5f) * 0.4f : 0.0f);
float b = 0.6f - t * 0.5f;
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}
static Color CmapHot(float t)
{
float r = Clamp(t * 3.0f, 0.0f, 1.0f);
float g = Clamp((t - 0.33f) * 3.0f, 0.0f, 1.0f);
float b = Clamp((t - 0.66f) * 3.0f, 0.0f, 1.0f);
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}
static Color CmapCool(float t)
{
return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 };
}
typedef struct { const char* name; ColormapFn fn; } ColormapDef;
static const ColormapDef COLORMAPS[COLORMAP_COUNT] = {
[COLORMAP_GRAYS] = { "Grays", CmapGrays },
[COLORMAP_INFERNO] = { "Inferno", CmapInferno },
[COLORMAP_VIRIDIS] = { "Viridis", CmapViridis },
[COLORMAP_PLASMA] = { "Plasma", CmapPlasma },
[COLORMAP_HOT] = { "Hot", CmapHot },
[COLORMAP_COOL] = { "Cool", CmapCool },
};
static Color GetColormapColor(float t, ColormapType type)
{
if (type < 0 || type >= COLORMAP_COUNT) return GRAY;
return COLORMAPS[type].fn(Clamp(t, 0.0f, 1.0f));
}
const char* ColormapName(ColormapType type)
{
if (type < 0 || type >= COLORMAP_COUNT) return "?";
return COLORMAPS[type].name;
}
void GenerateColormapTexture(void)
{
if (colormapTexture.id != 0) UnloadTexture(colormapTexture);
Image img = GenImageColor(256, 1, WHITE);
Color* pixels = (Color*)img.data;
for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap);
colormapTexture = LoadTextureFromImage(img);
UnloadImage(img);
}
// ===== Spectrogram texture =====
// ===== SYNCHROSQUEEZING (energy reassignment) =====
// The expensive part: reassign energy to true frequencies using the derivative
// STFT. Depends only on the STFT data, so the result is cached in
// app.reassignBuffer and reused by ColorizeSpectrogram across dB-floor /
// colormap changes (which don't need to recompute any of this).
static void ComputeSpectrogramReassignment(StftResult* stft)
{
if (stft->numSegments == 0) return;
int width = stft->numSegments;
int height = stft->segments[0].numBins;
int fftSize = (height - 1) * 2;
float freqPerBin = (float)stft->sampleRate / fftSize;
// (Re)allocate the cached accumulation buffer for reassigned energy.
free(app.reassignBuffer);
app.reassignBuffer = (float*)calloc(width * height, sizeof(float));
app.reassignWidth = width;
app.reassignHeight = height;
float* accumBuffer = app.reassignBuffer;
// Find max amplitude for normalization (skip NULL segments)
float maxAmplitude = 0.0001f;
for (int seg = 0; seg < stft->numSegments; seg++) {
if (stft->segments[seg].spectrum == NULL) continue;
for (int bin = 0; bin < stft->segments[seg].numBins; bin++)
if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude)
maxAmplitude = stft->segments[seg].spectrum[bin].amplitude;
}
// Noise threshold: only reassign bins with significant energy
float noiseThreshold = maxAmplitude * 0.01f; // 1% of max amplitude
for (int seg = 0; seg < width; seg++) {
// Skip segments that haven't been computed yet (overview/high-res transition)
if (stft->segments[seg].spectrum == NULL) continue;
for (int bin = 0; bin < height; bin++) {
FrequencyData* V_f = &stft->segments[seg].spectrum[bin];
FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin];
float amplitude = V_f->amplitude;
// Skip noise bins
if (amplitude < noiseThreshold) continue;
// Compute instantaneous frequency using synchrosqueezing formula:
// ω̂ = bin_freq + Re[V_fd / (i * V_f)]
// Complex division: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²)
// We need Re[(a+bi) / (i*(c+di))] = Re[(a+bi) / (-d+ci)] = (ad+bc)/(c²+d²)
float V_f_real = amplitude * cosf(V_f->phase);
float V_f_imag = amplitude * sinf(V_f->phase);
float V_fd_real = V_fd->amplitude * cosf(V_fd->phase);
float V_fd_imag = V_fd->amplitude * sinf(V_fd->phase);
float denom = V_f_real * V_f_real + V_f_imag * V_f_imag;
float trueFreq = V_f->frequency; // Default to bin frequency
if (denom > 1e-10f) {
// Re[V_fd / (i * V_f)] = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom
// Note the MINUS sign on the first term
float correction = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom;
trueFreq = V_f->frequency + correction;
}
// Clamp to valid range
if (trueFreq < 0) trueFreq = 0;
if (trueFreq >= stft->sampleRate / 2.0f) trueFreq = stft->sampleRate / 2.0f - 1;
// Map to bin coordinate
float targetBinF = trueFreq / freqPerBin;
if (targetBinF < 0) targetBinF = 0;
if (targetBinF >= height) targetBinF = height - 0.001f;
// Bilinear splatting to neighboring bins
int bin0 = (int)targetBinF;
int bin1 = bin0 + 1;
if (bin1 >= height) bin1 = height - 1;
float frac = targetBinF - bin0;
int idx0 = (height - 1 - bin0) * width + seg;
int idx1 = (height - 1 - bin1) * width + seg;
accumBuffer[idx0] += amplitude * (1 - frac);
accumBuffer[idx1] += amplitude * frac;
}
}
}
// Map the cached reassignment buffer to colors using the current dB floor/
// ceiling and colormap. Cheap — safe to call every frame the dB slider moves
// or when the colormap changes (no synchrosqueezing recompute).
void ColorizeSpectrogram(Image* image, Texture2D* texture)
{
if (app.reassignBuffer == NULL) return;
int width = app.reassignWidth;
int height = app.reassignHeight;
float* accumBuffer = app.reassignBuffer;
UnloadImage(*image); // release previous image (NULL-safe on first call)
*image = GenImageColor(width, height, BLACK);
Color* pixels = (Color*)image->data;
// Guard the range: if the floor reaches/exceeds the ceiling the division
// would otherwise go to ~0 (everything clamps white) or negative (the
// colors invert). A >=1 dB range degrades gracefully to a hard threshold.
float range = app.amplitudeCeilingDb - app.amplitudeFloorDb;
if (range < 1.0f) range = 1.0f;
for (int i = 0; i < width * height; i++) {
if (accumBuffer[i] > 0.0001f) {
float db = AmplitudeToDecibels(accumBuffer[i]);
float normalized = (db - app.amplitudeFloorDb) / range;
normalized = Clamp(normalized, 0.0f, 1.0f);
pixels[i] = GetColormapColor(normalized, app.colormap);
}
}
if (texture->id != 0) UnloadTexture(*texture);
*texture = LoadTextureFromImage(*image);
SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR);
}
// Recompute the reassignment (STFT changed) and rebuild the texture.
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture)
{
if (stft->numSegments == 0) return;
ComputeSpectrogramReassignment(stft);
ColorizeSpectrogram(image, texture);
}
// Compute auto-adjusted amplitude floor/ceiling from STFT data
// ===== Grid, labels, selection, playhead =====
void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color)
{
float cellWidth = bounds.width / numCellsX, cellHeight = bounds.height / numCellsY;
for (int i = 0; i <= numCellsX; i++) {
float x = bounds.x + i * cellWidth;
DrawLineV((Vector2){ x, bounds.y }, (Vector2){ x, bounds.y + bounds.height }, color);
}
for (int i = 0; i <= numCellsY; i++) {
float y = bounds.y + bounds.height - i * cellHeight;
DrawLineV((Vector2){ bounds.x, y }, (Vector2){ bounds.x + bounds.width, y }, color);
}
}
void DrawLabels(Rectangle bounds)
{
int baseFontSize = 12;
Color textColor = LIGHTGRAY;
// Time labels
for (int i = 0; i <= 10; i++) {
float t = (float)i / 10;
float timeSec = (app.view.start + t * (app.view.end - app.view.start)) * app.signal.duration;
float x = bounds.x + t * bounds.width;
char label[32];
if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60);
else sprintf(label, "%.1fs", timeSec);
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
}
// 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;
// Choose tick spacing based on zoom range
float freqRange = freqMax - freqMin;
int tickSpacing;
if (freqRange < 20) tickSpacing = 5;
else if (freqRange < 50) tickSpacing = 10;
else if (freqRange < 200) tickSpacing = 50;
else if (freqRange < 1000) tickSpacing = 100;
else if (freqRange < 5000) tickSpacing = 200;
else if (freqRange < 20000) tickSpacing = 1000;
else if (freqRange < 50000) tickSpacing = 5000;
else tickSpacing = 10000;
// Labels use next coarser spacing so they stay readable
int labelSpacing = tickSpacing;
if (labelSpacing <= 10) labelSpacing = 10;
else if (labelSpacing <= 50) labelSpacing = 50;
else if (labelSpacing <= 200) labelSpacing = 200;
else if (labelSpacing <= 1000) labelSpacing = 1000;
else if (labelSpacing <= 5000) labelSpacing = 5000;
else labelSpacing = 10000;
// Round freqMin up to nearest tick spacing (smallest multiple >= freqMin)
int firstTick = ((int)(freqMin / tickSpacing)) * tickSpacing;
if (firstTick < freqMin) firstTick += tickSpacing;
for (int hz = firstTick; hz <= freqMax; hz += tickSpacing) {
float t = (hz - freqMin) / freqRange;
float y = bounds.y + bounds.height - t * bounds.height;
Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f);
DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor);
}
// Draw labels at the coarser spacing
for (int hz = firstTick; hz <= freqMax; hz += labelSpacing) {
float t = (hz - freqMin) / freqRange;
float y = bounds.y + bounds.height - t * bounds.height;
char label[32];
if (hz < 10000) sprintf(label, "%.0fHz", (float)hz);
else sprintf(label, "%.0fkHz", (float)hz / 1000.0f);
DrawTextScaled(label, bounds.x - 70, y - 5, baseFontSize, textColor);
}
}
// Build the selection-box readout: time-domain stats (whole-band waveform in
// the time span) plus frequency-domain stats for the boxed band. Returns the
// number of lines written. Reads the global app state (sel / signal / stft).
static int BuildSelectionStatLines(char lines[][128], int maxLines)
{
int startSample = (int)(app.sel.timeStart * app.signal.numSamples);
int endSample = (int)(app.sel.timeEnd * app.signal.numSamples);
SignalStats t = ComputeSignalStats(&app.signal, startSample, endSample);
if (t.durationSec <= 0.0f || app.signal.samples == NULL) return 0;
int n = 0;
if (n < maxLines) sprintf(lines[n++], "Duration: %.3fs", t.durationSec);
if (n < maxLines) sprintf(lines[n++], "Energy: %.2f", t.energy);
if (n < maxLines) sprintf(lines[n++], "Peak: %.3f", t.peakAmplitude);
if (n < maxLines) sprintf(lines[n++], "RMS: %.3f", t.rmsAmplitude);
if (n < maxLines) sprintf(lines[n++], "PAPR: %.1f dB", t.paprDb);
if (app.stftComputed) {
SpectralStats s = ComputeSpectralStats(&app.stft, app.sel.timeStart,
app.sel.timeEnd, app.sel.freqStart, app.sel.freqEnd);
if (s.valid) {
if (n < maxLines) sprintf(lines[n++], "Peak f: %.0f Hz", s.peakFreqHz);
if (n < maxLines) sprintf(lines[n++], "Center: %.0f Hz", s.centroidHz);
if (n < maxLines) sprintf(lines[n++], "Occ BW: %.0f Hz", s.bandwidthHz);
if (n < maxLines) sprintf(lines[n++], "SNR: %.1f dB", s.snrDb);
}
}
return n;
}
// Draw the selection readout box beside the (already-normalized, screen-space)
// selection rect `sel`, placed to its right or left and clamped to `bounds`.
static void DrawStatPanel(Rectangle bounds, Rectangle sel)
{
char lines[12][128];
int lineCount = BuildSelectionStatLines(lines, 12);
if (lineCount == 0) return;
int fontSize = 10;
int maxTextW = 0;
for (int i = 0; i < lineCount; i++) {
int w = MeasureText(lines[i], fontSize);
if (w > maxTextW) maxTextW = w;
}
int boxW = maxTextW + 20;
int boxH = lineCount * 14 + 12;
float selLeft = sel.x, selRight = sel.x + sel.width;
float selCenterY = sel.y + sel.height * 0.5f;
// Normally prefer the right of the selection, falling back to the left. But
// the spectrum panel lives in the top-right, so when it's up bias the other
// way to avoid stacking the two boxes on top of each other.
float boxX;
if (app.showSpectrum) {
boxX = selLeft - boxW - 10;
if (boxX < bounds.x) {
boxX = selRight + 10;
if (boxX + boxW > bounds.x + bounds.width) boxX = bounds.x;
}
} else {
boxX = selRight + 10;
if (boxX + boxW > bounds.x + bounds.width) {
boxX = selLeft - boxW - 10;
if (boxX < bounds.x) boxX = bounds.x;
}
}
float boxY = selCenterY - boxH / 2.0f;
if (boxY < bounds.y) boxY = bounds.y;
if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH;
DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 });
DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f));
for (int i = 0; i < lineCount; i++) {
DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY);
}
}
void DrawSelection(Rectangle bounds)
{
// Only draw if selection is not full range AND not currently dragging
bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
if (!hasSelection || app.sel.isTimeSelecting) return; // Don't draw overlay while dragging
Color overlayColor = Fade(BLACK, 0.25f); // Lighter overlay
// Convert signal coordinates to viewport coordinates
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float selStartX = bounds.x + ((app.sel.timeStart - app.view.start) / viewWidth) * bounds.width;
float selEndX = bounds.x + ((app.sel.timeEnd - app.view.start) / viewWidth) * bounds.width;
float selStartY = bounds.y + bounds.height - ((app.sel.freqEnd - app.view.freqStart) / freqWidth) * bounds.height;
float selEndY = bounds.y + bounds.height - ((app.sel.freqStart - app.view.freqStart) / freqWidth) * bounds.height;
// Clamp to viewport bounds
selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX));
selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX));
selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY));
selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY));
// Draw overlay outside the selection box
DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor);
DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor);
DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor);
DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor);
// Draw selection box border
DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW);
// Readout box beside the selection.
DrawStatPanel(bounds, (Rectangle){ fminf(selStartX, selEndX), fminf(selStartY, selEndY),
fabsf(selEndX - selStartX), fabsf(selEndY - selStartY) });
}
void DrawSelectionDrag(Rectangle bounds)
{
// Draw bounding box while dragging (no overlay)
if ((!app.sel.isTimeSelecting && !app.sel.isFreqSelecting && !app.sel.isDragging) ||
(app.sel.isDragging && !IsMouseButtonDown(MOUSE_LEFT_BUTTON))) return;
// Convert signal coordinates to viewport coordinates
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float selStartX = bounds.x + ((app.sel.timeStart - app.view.start) / viewWidth) * bounds.width;
float selEndX = bounds.x + ((app.sel.timeEnd - app.view.start) / viewWidth) * bounds.width;
float selStartY = bounds.y + bounds.height - ((app.sel.freqEnd - app.view.freqStart) / freqWidth) * bounds.height;
float selEndY = bounds.y + bounds.height - ((app.sel.freqStart - app.view.freqStart) / freqWidth) * bounds.height;
// Clamp to viewport bounds
selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX));
selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX));
selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY));
selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY));
// Normalize coordinates for drawing
float x = selStartX < selEndX ? selStartX : selEndX;
float w = fabsf(selEndX - selStartX);
float y = selStartY < selEndY ? selStartY : selEndY;
float h = fabsf(selEndY - selStartY);
DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW);
// Live readout box while dragging.
DrawStatPanel(bounds, (Rectangle){ x, y, w, h });
}
// Floating time/frequency/level tag that follows the cursor over the
// spectrogram (suppressed while selecting/panning, which have their own
// readout). The level is the STFT magnitude at that bin, in dB.
void DrawCursorReadout(Rectangle bounds)
{
if (!app.loaded || !app.stftComputed || app.signal.samples == NULL) return;
if (app.sel.isTimeSelecting || app.sel.isFreqSelecting || app.sel.isDragging ||
app.view.isPanning || app.marker.dragging) return;
Vector2 m = GetMousePosition();
if (!CheckCollisionPointRec(m, bounds)) return;
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;
// 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] = "--";
if (app.stft.numSegments > 0) {
int seg = (int)(tFrac * app.stft.numSegments);
if (seg < 0) seg = 0;
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 = 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;
sprintf(level, "%.1f dB", AmplitudeToDecibels(s->spectrum[bin].amplitude));
}
}
char text[80];
sprintf(text, "%.3fs %.0f Hz %s", timeSec, freqHz, level);
int fontSize = 10;
int tw = MeasureText(text, fontSize);
int boxW = tw + 12, boxH = fontSize + 8;
// Offset up-right of the cursor; flip to keep it inside the viewport.
float bx = m.x + 14, by = m.y - boxH - 6;
if (bx + boxW > bounds.x + bounds.width) bx = m.x - boxW - 14;
if (by < bounds.y) by = m.y + 14;
DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 200 });
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(SKYBLUE, 0.6f));
DrawText(text, (int)bx + 6, (int)by + 4, fontSize, (Color){ 180, 220, 255, 255 });
}
// ============================================================================
// Marker / delta ruler
// ============================================================================
// Map a marker's normalized (t, f) to a screen point inside `bounds`, honoring
// the current zoom. Returns false if it falls outside the visible view.
static bool MarkerToScreen(Rectangle bounds, float t, float f, Vector2* out)
{
float vw = app.view.end - app.view.start;
float fw = app.view.freqEnd - app.view.freqStart;
if (vw <= 0.0f || fw <= 0.0f) return false;
float tx = (t - app.view.start) / vw;
float fy = (f - app.view.freqStart) / fw;
out->x = bounds.x + tx * bounds.width;
out->y = bounds.y + bounds.height - fy * bounds.height;
return (tx >= -0.05f && tx <= 1.05f && fy >= -0.05f && fy <= 1.05f);
}
static void DrawMarkerCross(Vector2 p, Color c, const char* tag)
{
DrawLine((int)p.x - 7, (int)p.y, (int)p.x + 7, (int)p.y, c);
DrawLine((int)p.x, (int)p.y - 7, (int)p.x, (int)p.y + 7, c);
DrawCircleLines((int)p.x, (int)p.y, 4, c);
DrawText(tag, (int)p.x + 7, (int)p.y - 14, 10, c);
}
// Two-point ruler overlay: crosshairs at A and B, a connecting line, and a
// readout of the time/frequency deltas plus the ham-useful derived rates
// (tone spacing 1/Δt and drift Δf/Δt).
void DrawMarkers(Rectangle bounds)
{
if (!app.markerMode || !app.marker.active || !app.loaded) return;
Vector2 a, b;
bool aIn = MarkerToScreen(bounds, app.marker.t0, app.marker.f0, &a);
bool bIn = MarkerToScreen(bounds, app.marker.t1, app.marker.f1, &b);
BeginScissorMode((int)bounds.x, (int)bounds.y, (int)bounds.width, (int)bounds.height);
DrawLineEx(a, b, 1.0f, Fade(ORANGE, 0.8f));
if (aIn) DrawMarkerCross(a, ORANGE, "A");
if (bIn) DrawMarkerCross(b, (Color){ 255, 180, 80, 255 }, "B");
EndScissorMode();
// 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);
float df = fabsf(fb - fa);
char lines[6][64];
int n = 0;
sprintf(lines[n++], "A: %.3fs %.0fHz", ta, fa);
sprintf(lines[n++], "B: %.3fs %.0fHz", tb, fb);
if (dt >= 1.0f) sprintf(lines[n++], "dt: %.3f s", dt);
else if (dt >= 0.001f) sprintf(lines[n++], "dt: %.1f ms", dt * 1000.0f);
else sprintf(lines[n++], "dt: %.3f ms", dt * 1000.0f);
sprintf(lines[n++], "df: %.1f Hz", df);
if (dt > 1e-6f) sprintf(lines[n++], "1/dt: %.2f Hz", 1.0f / dt);
if (dt > 1e-6f) sprintf(lines[n++], "slope: %.0f Hz/s", (fb - fa) / dt);
int fontSize = 10;
int maxW = 0;
for (int i = 0; i < n; i++) { int w = MeasureText(lines[i], fontSize); if (w > maxW) maxW = w; }
int boxW = maxW + 16, boxH = n * 14 + 10;
// Anchor near B (or A if B is off-view), clamped inside bounds.
Vector2 anchor = bIn ? b : a;
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, 210 });
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(ORANGE, 0.7f));
for (int i = 0; i < n; i++)
DrawText(lines[i], (int)bx + 8, (int)by + 6 + i * 14, fontSize, (Color){ 255, 210, 160, 255 });
}
// ============================================================================
// Spectrum slice (averaged PSD of the selection / view)
// ============================================================================
// Floating panel plotting the time-averaged power spectrum over frequency.
// Region: the selection box if one exists, otherwise the visible view. The
// frequency axis spans that band; the curve is power in dB (auto-ranged).
void DrawSpectrumPanel(Rectangle bounds)
{
if (!app.showSpectrum || !app.loaded || !app.stftComputed) return;
if (app.stft.numSegments <= 0) return;
bool hasSel = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
float t0 = hasSel ? app.sel.timeStart : app.view.start;
float t1 = hasSel ? app.sel.timeEnd : app.view.end;
float f0 = hasSel ? app.sel.freqStart : app.view.freqStart;
float f1 = hasSel ? app.sel.freqEnd : app.view.freqEnd;
if (f1 < f0) { float t = f0; f0 = f1; f1 = t; }
int maxBins = FFT_SIZE_MAX / 2 + 1;
float* power = (float*)malloc(maxBins * sizeof(float));
if (!power) return;
int nbins = ComputePowerSpectrum(&app.stft, t0, t1, power, maxBins);
if (nbins < 2) { free(power); return; }
float nyquist = app.signal.sampleRate * 0.5f;
float binHz = nyquist / (float)(nbins - 1);
float freqLow = f0 * nyquist, freqHigh = f1 * nyquist;
int binLo = (int)floorf(freqLow / binHz);
int binHi = (int)ceilf(freqHigh / binHz);
if (binLo < 0) binLo = 0;
if (binHi > nbins - 1) binHi = nbins - 1;
if (binHi <= binLo) { free(power); return; }
// Auto-range the dB axis over the displayed band.
float maxDb = -200.0f, minDb = 200.0f;
int peakBin = binLo;
for (int b = binLo; b <= binHi; b++) {
float db = 10.0f * log10f(power[b] + 1e-20f);
if (db > maxDb) { maxDb = db; peakBin = b; }
if (db < minDb) minDb = db;
}
if (maxDb - minDb < 6.0f) minDb = maxDb - 6.0f; // avoid a flat line filling the panel
float ceilDb = maxDb + 2.0f, floorDb = minDb - 2.0f;
float rangeDb = ceilDb - floorDb;
// Panel: top-right of the viewport, semi-transparent.
float scale = GetUIScale();
float panelW = fminf(360.0f * scale, bounds.width * 0.55f);
float panelH = 170.0f * scale;
Rectangle panel = { bounds.x + bounds.width - panelW - 10.0f * scale, bounds.y + 10.0f * scale,
panelW, panelH };
DrawRectangleRec(panel, (Color){ 12, 14, 20, 220 });
DrawRectangleLinesEx(panel, 1, Fade(SKYBLUE, 0.7f));
// Plot area inside the panel (leave room for labels).
float padL = 6.0f * scale, padR = 6.0f * scale, padT = 18.0f * scale, padB = 14.0f * scale;
Rectangle plot = { panel.x + padL, panel.y + padT,
panel.width - padL - padR, panel.height - padT - padB };
DrawText(hasSel ? "Spectrum (selection)" : "Spectrum (view)",
(int)(panel.x + 6 * scale), (int)(panel.y + 4 * scale), 10, (Color){ 180, 220, 255, 255 });
float freqSpan = freqHigh - freqLow;
if (freqSpan < 1e-6f) freqSpan = 1e-6f;
// The curve: one polyline vertex per pixel column, mapping x -> freq -> bin.
Vector2 prev = { 0 };
bool havePrev = false;
for (int px = 0; px <= (int)plot.width; px++) {
float fr = freqLow + (px / plot.width) * freqSpan;
int b = (int)(fr / binHz + 0.5f);
if (b < 0) b = 0;
if (b > nbins - 1) b = nbins - 1;
float db = 10.0f * log10f(power[b] + 1e-20f);
float norm = (db - floorDb) / rangeDb;
norm = Clamp(norm, 0.0f, 1.0f);
Vector2 cur = { plot.x + px, plot.y + plot.height - norm * plot.height };
if (havePrev) DrawLineV(prev, cur, (Color){ 120, 220, 255, 255 });
prev = cur;
havePrev = true;
}
// Peak marker + label.
float peakFr = peakBin * binHz;
float peakX = plot.x + ((peakFr - freqLow) / freqSpan) * plot.width;
if (peakX >= plot.x && peakX <= plot.x + plot.width) {
DrawLine((int)peakX, (int)plot.y, (int)peakX, (int)(plot.y + plot.height), Fade(YELLOW, 0.5f));
}
// Axis labels: frequency at the ends, peak dB at top-left of the plot.
char lbl[32];
sprintf(lbl, "%.0f Hz", freqLow);
DrawText(lbl, (int)plot.x, (int)(panel.y + panel.height - 12 * scale), 9, GRAY);
sprintf(lbl, "%.0f Hz", freqHigh);
DrawText(lbl, (int)(plot.x + plot.width - MeasureText(lbl, 9)),
(int)(panel.y + panel.height - 12 * scale), 9, GRAY);
sprintf(lbl, "pk %.0fHz %.0fdB", peakFr, maxDb);
DrawText(lbl, (int)(plot.x + 2), (int)(plot.y + 1), 9, Fade(YELLOW, 0.85f));
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
// ============================================================================
void DrawPlayhead(Rectangle bounds)
{
if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return;
float timePos = app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart);
float viewWidth = app.view.end - app.view.start;
float t = (timePos - app.view.start) / viewWidth;
float x = bounds.x + t * bounds.width;
// Clamp to bounds
x = fmaxf(bounds.x, fminf(bounds.x + bounds.width, x));
// Draw vertical line
DrawLine(x, bounds.y, x, bounds.y + bounds.height, RED);
// Draw semi-transparent overlay to make it stand out
DrawRectangle(x - 2, bounds.y, 4, bounds.height, (Color){ 255, 0, 0, 60 });
}