feat: frequency-aware selection stats (peak/center freq, occupied BW, SNR)

The selection readout was time-domain only — Energy/Peak/RMS/PAPR computed
from the whole-bandwidth waveform in the time span, ignoring the box's
frequency bounds entirely. The 2D box only measured one axis.

Add ComputeSpectralStats (stft.c): measures the boxed band from the STFT
magnitude (not the synchrosqueezed display buffer, which relocates energy)
and reports peak frequency, power-weighted centroid ("power center"),
occupied bandwidth (in-band span >3 dB over a median noise floor — robust
for both tones and noise-like bursts), and in-band SNR.

Also fold the two near-identical stats-panel blocks in render.c into one
DrawStatPanel + BuildSelectionStatLines helper so the live-drag and
committed-selection readouts can't drift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 10:26:37 -07:00
parent f833ed17a1
commit 26afc4b30e
3 changed files with 207 additions and 126 deletions
+77 -126
View File
@@ -1,5 +1,7 @@
// 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>
@@ -338,6 +340,76 @@ void DrawLabels(Rectangle bounds)
}
}
// 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;
// Prefer the right of the selection; fall back to the left, then clamp.
float boxX = selRight + 10;
if (boxX + boxW > bounds.x + bounds.width) {
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;
}
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
@@ -371,69 +443,9 @@ void DrawSelection(Rectangle bounds)
// Draw selection box border
DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW);
// Display selection stats inside viewport, clamped to fit
{
int startSample = (int)(app.sel.timeStart * app.signal.numSamples);
int endSample = (int)(app.sel.timeEnd * app.signal.numSamples);
SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample);
if (stats.durationSec > 0.0f && app.signal.samples != NULL) {
char lines[5][128];
int lineCount = 0;
int fontSize = 10;
int maxTextW = 0;
sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec);
sprintf(lines[lineCount++], "Energy: %.2f", stats.energy);
sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude);
sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude);
sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb);
// Measure text width
for (int i = 0; i < lineCount; i++) {
int textW = MeasureText(lines[i], fontSize);
if (textW > maxTextW) maxTextW = textW;
}
int boxW = maxTextW + 20;
int boxH = lineCount * 14 + 12;
// Center vertically on the selection box, clamp to viewport
float selCenterY = (selStartY + selEndY) / 2.0f;
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;
// Place to the right of the selection, or left if not enough room
float boxX = selEndX + 10;
if (boxX + boxW > bounds.x + bounds.width) {
boxX = selStartX - boxW - 10;
if (boxX < bounds.x) boxX = bounds.x;
}
// Clamp to viewport bounds
if (boxX + boxW > bounds.x + bounds.width) {
// Not enough room on right — draw to the left of selection
if (selStartX > boxW + 20) {
boxX = selStartX - boxW - 10;
} else {
boxX = bounds.x;
}
}
if (boxY + boxH > bounds.y + bounds.height) {
boxY = bounds.y + bounds.height - boxH;
}
// Draw background box
DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 });
DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f));
// Draw text
for (int i = 0; i < lineCount; i++) {
DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY);
}
}
}
// Readout box beside the selection.
DrawStatPanel(bounds, (Rectangle){ fminf(selStartX, selEndX), fminf(selStartY, selEndY),
fabsf(selEndX - selStartX), fabsf(selEndY - selStartY) });
}
void DrawSelectionDrag(Rectangle bounds)
@@ -465,69 +477,8 @@ void DrawSelectionDrag(Rectangle bounds)
DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW);
// Display live stats while dragging (inside viewport, clamped to fit)
{
int startSample = (int)(app.sel.timeStart * app.signal.numSamples);
int endSample = (int)(app.sel.timeEnd * app.signal.numSamples);
SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample);
if (stats.durationSec > 0.0f && app.signal.samples != NULL) {
char lines[5][128];
int lineCount = 0;
int fontSize = 10;
int maxTextW = 0;
sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec);
sprintf(lines[lineCount++], "Energy: %.2f", stats.energy);
sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude);
sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude);
sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb);
// Measure text width
for (int i = 0; i < lineCount; i++) {
int textW = MeasureText(lines[i], fontSize);
if (textW > maxTextW) maxTextW = textW;
}
int boxW = maxTextW + 20;
int boxH = lineCount * 14 + 12;
// Center vertically on the selection box, clamp to viewport
float selCenterY = (selStartY + selEndY) / 2.0f;
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;
// Place to the right of the selection, or left if not enough room
float boxX = selEndX + 10;
if (boxX + boxW > bounds.x + bounds.width) {
boxX = selStartX - boxW - 10;
if (boxX < bounds.x) boxX = bounds.x;
}
// Clamp to viewport bounds
if (boxX + boxW > bounds.x + bounds.width) {
// Not enough room on right — draw to the left of selection
if (x > boxW + 20) {
boxX = x - boxW - 10;
} else {
boxX = bounds.x;
}
}
if (boxY + boxH > bounds.y + bounds.height) {
boxY = bounds.y + bounds.height - boxH;
}
// Draw background box
DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 });
DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f));
// Draw text
for (int i = 0; i < lineCount; i++) {
DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY);
}
}
}
// Live readout box while dragging.
DrawStatPanel(bounds, (Rectangle){ x, y, w, h });
}
// ============================================================================