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
|
||||
#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 });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user