// 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 #include #include #include // ===== 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