ac262505c1
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>
1490 lines
66 KiB
C
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 });
|
|
}
|