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:
+113
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user