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
+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;
}