feat: spectrum-slice (PSD) panel + two-point marker/ruler tool

Spectrum slice (S): floating panel plotting the time-averaged power
spectrum of the current selection (or the visible view when there's no
selection). Frequency on X over the region's band, auto-ranged dB on Y,
with a peak marker. Backed by ComputePowerSpectrum() in stft.c (mean
linear power per bin over the time span). The selection stat panel now
biases to the left when this panel is up so the two don't overlap.

Marker/ruler tool (M): press-drag-release drops point A and B; the
overlay shows crosshairs, a connecting line, and a readout of the
ham-useful deltas — Δt, Δf, tone spacing (1/Δt), and drift (Δf/Δt).
Marker mode swaps the LMB-drag gesture from box-select to marker drop
(Alt/middle still pan); RMB/Esc clear the measurement. Markers reset on
new-file load alongside the selection.

Both are gated toggles (off by default), wired into the keymap (so they
self-document in the About dialog) and the sidebar.

Verified headlessly: idle viewport pixel-identical to baseline (AE=0,
deterministic); both panels render correctly with sensible numbers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 10:53:57 -07:00
parent 542126261e
commit e8ed19d338
7 changed files with 325 additions and 10 deletions
+200 -8
View File
@@ -390,14 +390,22 @@ static void DrawStatPanel(Rectangle bounds, Rectangle sel)
float selLeft = sel.x, selRight = sel.x + sel.width;
float selCenterY = sel.y + sel.height * 0.5f;
// Prefer the right of the selection; fall back to the left, then clamp.
float boxX = selRight + 10;
if (boxX + boxW > bounds.x + bounds.width) {
// 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 = bounds.x;
}
if (boxX + boxW > bounds.x + bounds.width) {
boxX = (selLeft > boxW + 20) ? selLeft - boxW - 10 : bounds.x;
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;
@@ -488,7 +496,7 @@ 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) return;
app.view.isPanning || app.marker.dragging) return;
Vector2 m = GetMousePosition();
if (!CheckCollisionPointRec(m, bounds)) return;
@@ -530,6 +538,190 @@ void DrawCursorReadout(Rectangle bounds)
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.
float nyquist = app.signal.sampleRate * 0.5f;
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);
}
// ============================================================================
// Playhead
// ============================================================================