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:
+77
-126
@@ -1,5 +1,7 @@
|
|||||||
// render.c - colormaps, spectrogram texture generation, and on-screen drawing
|
// render.c - colormaps, spectrogram texture generation, and on-screen drawing
|
||||||
#include "render.h"
|
#include "render.h"
|
||||||
|
#include "stft.h" // ComputeSpectralStats for the selection panel
|
||||||
|
#include "utils.h" // ComputeSignalStats
|
||||||
|
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
#include <stdlib.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)
|
void DrawSelection(Rectangle bounds)
|
||||||
{
|
{
|
||||||
// Only draw if selection is not full range AND not currently dragging
|
// Only draw if selection is not full range AND not currently dragging
|
||||||
@@ -371,69 +443,9 @@ void DrawSelection(Rectangle bounds)
|
|||||||
// Draw selection box border
|
// Draw selection box border
|
||||||
DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW);
|
DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW);
|
||||||
|
|
||||||
// Display selection stats inside viewport, clamped to fit
|
// Readout box beside the selection.
|
||||||
{
|
DrawStatPanel(bounds, (Rectangle){ fminf(selStartX, selEndX), fminf(selStartY, selEndY),
|
||||||
int startSample = (int)(app.sel.timeStart * app.signal.numSamples);
|
fabsf(selEndX - selStartX), fabsf(selEndY - selStartY) });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DrawSelectionDrag(Rectangle bounds)
|
void DrawSelectionDrag(Rectangle bounds)
|
||||||
@@ -465,69 +477,8 @@ void DrawSelectionDrag(Rectangle bounds)
|
|||||||
|
|
||||||
DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW);
|
DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW);
|
||||||
|
|
||||||
// Display live stats while dragging (inside viewport, clamped to fit)
|
// Live readout box while dragging.
|
||||||
{
|
DrawStatPanel(bounds, (Rectangle){ x, y, w, h });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+113
@@ -359,3 +359,116 @@ void AutoScaleAmplitude(StftResult* stft)
|
|||||||
app.amplitudeCeilingDb = maxDb;
|
app.amplitudeCeilingDb = maxDb;
|
||||||
app.amplitudeFloorDb = maxDb - app.dynRangeDb;
|
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
@@ -14,6 +14,23 @@ void FreeSTFT(StftResult* result);
|
|||||||
int ComputeSkipFactor(float signalDurationSec);
|
int ComputeSkipFactor(float signalDurationSec);
|
||||||
void AutoScaleAmplitude(StftResult* stft);
|
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 ---
|
// --- FFT-size handling & cache ---
|
||||||
void ChangeFFTSize(int newFFT);
|
void ChangeFFTSize(int newFFT);
|
||||||
void SaveToCache(void);
|
void SaveToCache(void);
|
||||||
|
|||||||
Reference in New Issue
Block a user