From 26afc4b30e05773caa61f433af05f2d414924cd8 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 25 May 2026 10:26:37 -0700 Subject: [PATCH] feat: frequency-aware selection stats (peak/center freq, occupied BW, SNR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/render.c | 203 +++++++++++++++++++-------------------------------- src/stft.c | 113 ++++++++++++++++++++++++++++ src/stft.h | 17 +++++ 3 files changed, 207 insertions(+), 126 deletions(-) diff --git a/src/render.c b/src/render.c index 2e5ea08..931689b 100644 --- a/src/render.c +++ b/src/render.c @@ -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 #include @@ -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 }); } // ============================================================================ diff --git a/src/stft.c b/src/stft.c index ad963ea..09cf3f8 100644 --- a/src/stft.c +++ b/src/stft.c @@ -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; +} diff --git a/src/stft.h b/src/stft.h index 9284485..659c5c2 100644 --- a/src/stft.h +++ b/src/stft.h @@ -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);