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 });
}
// ============================================================================
+113
View File
@@ -359,3 +359,116 @@ void AutoScaleAmplitude(StftResult* stft)
app.amplitudeCeilingDb = maxDb;
app.amplitudeFloorDb = maxDb - app.dynRangeDb;
}
static int CompareDouble(const void* a, const void* b)
{
double da = *(const double*)a, db = *(const double*)b;
return (da > db) - (da < db);
}
SpectralStats ComputeSpectralStats(const StftResult* stft,
float t0, float t1, float f0, float f1)
{
SpectralStats st = { 0 };
if (!stft || stft->numSegments <= 0 || stft->sampleRate <= 0) return st;
// Normalize + clamp the box to [0,1].
if (t1 < t0) { float tmp = t0; t0 = t1; t1 = tmp; }
if (f1 < f0) { float tmp = f0; f0 = f1; f1 = tmp; }
t0 = fmaxf(0.0f, t0); t1 = fminf(1.0f, t1);
f0 = fmaxf(0.0f, f0); f1 = fminf(1.0f, f1);
const float nyquist = stft->sampleRate * 0.5f;
const float freqLow = f0 * nyquist;
const float freqHigh = f1 * nyquist;
int segStart = (int)(t0 * stft->numSegments);
int segEnd = (int)(t1 * stft->numSegments);
if (segStart < 0) segStart = 0;
if (segEnd > stft->numSegments) segEnd = stft->numSegments;
if (segEnd <= segStart) segEnd = (segStart < stft->numSegments) ? segStart + 1 : segStart;
// Learn the bin count from the first computed segment in range.
int nbins = 0;
for (int s = segStart; s < segEnd; s++) {
if (stft->segments[s].spectrum && stft->segments[s].numBins > 0) {
nbins = stft->segments[s].numBins; break;
}
}
if (nbins < 2) return st;
// Mean power per bin over the selected time span (skip uncomputed segments).
double* power = (double*)calloc(nbins, sizeof(double));
if (!power) return st;
int counted = 0;
for (int s = segStart; s < segEnd; s++) {
const StftSegment* seg = &stft->segments[s];
if (!seg->spectrum || seg->numBins < nbins) continue;
for (int b = 0; b < nbins; b++) {
float a = seg->spectrum[b].amplitude;
power[b] += (double)a * a;
}
counted++;
}
if (counted == 0) { free(power); return st; }
for (int b = 0; b < nbins; b++) power[b] /= counted;
const float binHz = nyquist / (float)(nbins - 1); // = sampleRate / fftSize
int binLow = (int)ceilf(freqLow / binHz);
int binHigh = (int)floorf(freqHigh / binHz);
if (binLow < 0) binLow = 0;
if (binHigh > nbins - 1) binHigh = nbins - 1;
if (binHigh < binLow) { free(power); return st; } // band narrower than a bin
// Peak, centroid, total in-band power.
double sumP = 0.0, sumFP = 0.0, peakP = -1.0;
int peakBin = binLow;
for (int b = binLow; b <= binHigh; b++) {
double p = power[b];
double f = (double)b * binHz;
sumP += p; sumFP += f * p;
if (p > peakP) { peakP = p; peakBin = b; }
}
int K = binHigh - binLow + 1;
(void)peakP;
st.valid = true;
st.peakFreqHz = (float)(peakBin * binHz);
st.centroidHz = (sumP > 0.0) ? (float)(sumFP / sumP) : st.peakFreqHz;
st.inBandLevelDb = (sumP > 0.0) ? 10.0f * log10f((float)(sumP / K) + 1e-20f) : -200.0f;
// Robust noise floor: median power of the out-of-band bins (skip DC). Used
// for both the occupied-bandwidth threshold and the SNR estimate.
double noiseDensity = 0.0;
double* out = (double*)malloc(nbins * sizeof(double));
if (out) {
int outCount = 0;
for (int b = 1; b < nbins; b++) {
if (b < binLow || b > binHigh) out[outCount++] = power[b];
}
if (outCount > 0) {
qsort(out, outCount, sizeof(double), CompareDouble);
noiseDensity = out[outCount / 2];
}
free(out);
}
// Occupied bandwidth: span of the in-band region sitting >3 dB over noise.
// Robust for both pure tones (narrow) and noise-like bursts (wide), unlike
// a -3 dB-around-peak walk which collapses to one bin on rough spectra.
double thresh = noiseDensity * 2.0; // +3 dB
int lo = -1, hi = -1;
for (int b = binLow; b <= binHigh; b++) {
if (power[b] >= thresh) { if (lo < 0) lo = b; hi = b; }
}
st.bandwidthHz = (lo >= 0) ? (float)((hi - lo + 1) * binHz) : 0.0f;
// SNR: in-band power above the noise floor scaled to the in-band bin count.
double noiseInBand = noiseDensity * K;
double sig = sumP - noiseInBand;
if (sig < 1e-20) sig = 1e-20;
if (noiseInBand < 1e-20) noiseInBand = 1e-20;
st.snrDb = 10.0f * log10f((float)(sig / noiseInBand));
free(power);
return st;
}
+17
View File
@@ -14,6 +14,23 @@ void FreeSTFT(StftResult* result);
int ComputeSkipFactor(float signalDurationSec);
void AutoScaleAmplitude(StftResult* stft);
// --- Spectral measurement of a selection box ---
// Frequency-domain stats for the region [t0,t1]x[f0,f1] (all 0-1 normalized;
// f is a fraction of Nyquist). Measured from the STFT magnitude, NOT the
// synchrosqueezed display buffer (which relocates energy for sharpness and
// isn't a faithful per-bin power).
typedef struct {
bool valid;
float peakFreqHz; // frequency of the strongest bin in the band
float centroidHz; // power-weighted mean frequency ("power center")
float bandwidthHz; // occupied width: in-band span sitting >3 dB over noise
float inBandLevelDb; // mean in-band level, 20*log10(amplitude)
float snrDb; // in-band power vs surrounding noise floor (median)
} SpectralStats;
SpectralStats ComputeSpectralStats(const StftResult* stft,
float t0, float t1, float f0, float f1);
// --- FFT-size handling & cache ---
void ChangeFFTSize(int newFFT);
void SaveToCache(void);