feat: live cursor readout (time / frequency / STFT level)

Floating tag follows the pointer over the spectrogram showing the time,
frequency, and STFT magnitude (dB) under the cursor — the standard
spectrum-analyzer probe. Suppressed while selecting/panning (which have
their own readout) and when a modal is open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 10:29:36 -07:00
parent 26afc4b30e
commit ee9eac786c
3 changed files with 51 additions and 0 deletions
+49
View File
@@ -481,6 +481,55 @@ void DrawSelectionDrag(Rectangle bounds)
DrawStatPanel(bounds, (Rectangle){ x, y, w, h });
}
// Floating time/frequency/level tag that follows the cursor over the
// spectrogram (suppressed while selecting/panning, which have their own
// readout). The level is the STFT magnitude at that bin, in dB.
void DrawCursorReadout(Rectangle bounds)
{
if (!app.loaded || !app.stftComputed || app.signal.samples == NULL) return;
if (app.sel.isTimeSelecting || app.sel.isFreqSelecting || app.sel.isDragging ||
app.view.isPanning) return;
Vector2 m = GetMousePosition();
if (!CheckCollisionPointRec(m, bounds)) return;
float tFrac = app.view.start + ((m.x - bounds.x) / bounds.width) * (app.view.end - app.view.start);
float fFrac = app.view.freqStart + (1.0f - (m.y - bounds.y) / bounds.height) * (app.view.freqEnd - app.view.freqStart);
float timeSec = tFrac * app.signal.duration;
float nyquist = app.signal.sampleRate * 0.5f;
float freqHz = fFrac * nyquist;
// Sample the STFT level at this (time, freq).
char level[32] = "--";
if (app.stft.numSegments > 0) {
int seg = (int)(tFrac * app.stft.numSegments);
if (seg < 0) seg = 0;
if (seg >= app.stft.numSegments) seg = app.stft.numSegments - 1;
const StftSegment* s = &app.stft.segments[seg];
if (s->spectrum && s->numBins > 1) {
float binHz = nyquist / (float)(s->numBins - 1);
int bin = (int)(freqHz / binHz + 0.5f);
if (bin < 0) bin = 0;
if (bin >= s->numBins) bin = s->numBins - 1;
sprintf(level, "%.1f dB", AmplitudeToDecibels(s->spectrum[bin].amplitude));
}
}
char text[80];
sprintf(text, "%.3fs %.0f Hz %s", timeSec, freqHz, level);
int fontSize = 10;
int tw = MeasureText(text, fontSize);
int boxW = tw + 12, boxH = fontSize + 8;
// Offset up-right of the cursor; flip to keep it inside the viewport.
float bx = m.x + 14, by = m.y - boxH - 6;
if (bx + boxW > bounds.x + bounds.width) bx = m.x - boxW - 14;
if (by < bounds.y) by = m.y + 14;
DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 200 });
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(SKYBLUE, 0.6f));
DrawText(text, (int)bx + 6, (int)by + 4, fontSize, (Color){ 180, 220, 255, 255 });
}
// ============================================================================
// Playhead
// ============================================================================
+1
View File
@@ -25,6 +25,7 @@ void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color c
void DrawLabels(Rectangle bounds);
void DrawSelection(Rectangle bounds);
void DrawSelectionDrag(Rectangle bounds);
void DrawCursorReadout(Rectangle bounds);
void DrawPlayhead(Rectangle bounds);
#endif // RENDER_H
+1
View File
@@ -964,6 +964,7 @@ int main(int argc, char* argv[])
DrawSelectionDrag(viewBounds);
DrawPlayhead(viewBounds);
DrawLabels(viewBounds);
if (!UiModalOpen()) DrawCursorReadout(viewBounds);
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;