feat: dB scale modes (relative/absolute dBFS), about dialog, fix white-out

dB floor handling:
- Fix the white-out/inversion when the floor was cranked up: guard the
  (ceiling - floor) range in ColorizeSpectrogram so floor >= ceiling
  degrades to a hard threshold instead of dividing by ~0 (white) or a
  negative (inverted colors).
- Add two amplitude scale modes, toggled in the sidebar:
  * Relative: ceiling tracks the signal peak, floor sits N dB below it
    (slider = dynamic range, 10..100 dB). Floor can't cross the ceiling.
  * Absolute: fixed dBFS scale (0 dBFS = full scale), slider sets an
    absolute floor in dBFS. Brightness reflects real level.
  Mode + both slider values persist across loads. This replaces the
  amplitudeUserSet flag — storing the intent (range / absolute floor)
  preserves it across re-scales structurally.

About / Help dialog (no help menu existed):
- F1 or sidebar button; documents the scale modes and the caveats
  (dBFS not dBm — a WAV has no power reference; ~6 dB Hann window
  offset; pixels are reassigned energy, not raw bins) plus key list.
- Modal: underlying spectrogram input is gated while open; open/close
  handled in the main loop so the opening click can't self-close it and
  a dismissing click can't fall through into a selection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:43:02 -07:00
parent ddbbe2734c
commit 3863ab8620
6 changed files with 174 additions and 30 deletions
+107 -10
View File
@@ -355,17 +355,42 @@ void DrawSidebar(void)
DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE);
y += 32 * scale;
// dB Floor slider
DrawTextScaled(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, 14, LIGHTGRAY); y += 20 * scale;
Rectangle dbSlider = { x, y, sidebarWidth - 10 * scale, 20 * scale };
float dbValue = (app.amplitudeFloorDb + 100.0f) / 80.0f;
DrawSlider(dbSlider, dbValue);
if (UpdateSlider(dbSlider, &dbValue)) {
app.amplitudeFloorDb = -100.0f + dbValue * 80.0f;
app.amplitudeUserSet = true; // stop auto-scale from overwriting this
// Amplitude scale mode toggle (Relative dynamic range vs Absolute dBFS)
Rectangle scaleBtn = { x, y, sidebarWidth - 10 * scale, 22 * scale };
if (CheckCollisionPointRec(GetMousePosition(), scaleBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.amplitudeMode = (app.amplitudeMode == SCALE_RELATIVE) ? SCALE_ABSOLUTE : SCALE_RELATIVE;
AutoScaleAmplitude(&app.stft); // re-derive floor/ceiling for the new mode
needsRegen = true;
}
y += 28 * scale;
DrawRectangleRec(scaleBtn, (Color){ 50, 50, 60, 255 });
DrawRectangleLinesEx(scaleBtn, 1, GRAY);
DrawTextScaled(app.amplitudeMode == SCALE_ABSOLUTE ? "Scale: Absolute (dBFS)" : "Scale: Relative",
scaleBtn.x + 8 * scale, scaleBtn.y + 4 * scale, 13, WHITE);
y += 30 * scale;
// Floor control — meaning depends on the scale mode.
Rectangle dbSlider = { x, y + 20 * scale, sidebarWidth - 10 * scale, 20 * scale };
if (app.amplitudeMode == SCALE_ABSOLUTE) {
DrawTextScaled(TextFormat("Floor: %.1f dBFS", app.absoluteFloorDb), x, y, 14, LIGHTGRAY);
float dbValue = (app.absoluteFloorDb + 100.0f) / 100.0f; // -100..0 dBFS -> 0..1
DrawSlider(dbSlider, dbValue);
if (UpdateSlider(dbSlider, &dbValue)) {
app.absoluteFloorDb = -100.0f + dbValue * 100.0f;
app.amplitudeCeilingDb = 0.0f;
app.amplitudeFloorDb = app.absoluteFloorDb;
needsRegen = true;
}
} else {
DrawTextScaled(TextFormat("Dyn Range: %.0f dB", app.dynRangeDb), x, y, 14, LIGHTGRAY);
float dbValue = (100.0f - app.dynRangeDb) / 90.0f; // 100..10 dB -> 0..1 (right = more contrast)
DrawSlider(dbSlider, dbValue);
if (UpdateSlider(dbSlider, &dbValue)) {
app.dynRangeDb = 100.0f - dbValue * 90.0f;
app.amplitudeFloorDb = app.amplitudeCeilingDb - app.dynRangeDb;
needsRegen = true;
}
}
y += 48 * scale;
// Colormap dropdown
DrawTextScaled("Colormap:", x, y, 14, LIGHTGRAY); y += 20 * scale;
@@ -490,7 +515,18 @@ void DrawSidebar(void)
} else {
DrawTextScaled("No file loaded", x, y, 14, GRAY); y += 18 * scale;
}
y += 10 * scale;
// About / Help button
Rectangle aboutBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
if (CheckCollisionPointRec(GetMousePosition(), aboutBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.showAbout = true;
}
DrawRectangleRec(aboutBtn, (Color){ 50, 50, 60, 255 });
DrawRectangleLinesEx(aboutBtn, 1, GRAY);
DrawTextScaled("About / Help (F1)", aboutBtn.x + 8 * scale, aboutBtn.y + 5 * scale, 13, WHITE);
y += 30 * scale;
if (needsRegen && app.stftComputed) {
// dB floor / colormap only — re-map the cached reassignment, don't recompute it.
ColorizeSpectrogram(&app.spectrogramImage, &app.spectrogramTexture);
@@ -526,3 +562,64 @@ static bool UpdateSlider(Rectangle bounds, float* value)
}
return changed;
}
// ===== About / Help dialog =====
void DrawAboutDialog(void)
{
if (!app.showAbout) return;
float scale = GetUIScale();
int sw = GetScreenWidth();
int sh = GetScreenHeight();
// Dim the background; a click outside the panel (or Esc/F1) closes it.
DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.7f));
float pw = 560 * scale;
float ph = 470 * scale;
Rectangle panel = { (sw - pw) / 2, (sh - ph) / 2, pw, ph };
DrawRectangleRec(panel, (Color){ 30, 30, 38, 255 });
DrawRectangleLinesEx(panel, 2, (Color){ 90, 90, 110, 255 });
float px = panel.x + 24 * scale;
float py = panel.y + 20 * scale;
DrawTextScaled("rspektrum — synchrosqueezed spectrogram viewer", px, py, 18, WHITE);
py += 34 * scale;
// Body lines. NULL = blank spacer; headings start with no leading spaces.
const char* lines[] = {
"Amplitude scale",
" Relative: ceiling tracks the signal peak; the slider sets how many",
" dB of range are shown below it (good for seeing structure).",
" Absolute: fixed dBFS scale, 0 dBFS = digital full scale (sample = 1.0).",
" Brightness then reflects a real level, comparable across files.",
"",
"Why dBFS and not dBm",
" A WAV carries no power reference (impedance / receiver calibration),",
" so absolute levels are shown in dBFS, not dBm. 0 dBFS = full scale.",
"",
"Accuracy caveats",
" - The Hann window adds a fixed ~6 dB coherent-gain offset to",
" spectral magnitudes.",
" - Pixels show synchrosqueezed (reassigned) energy, not raw FFT bins.",
" Good to ~a dB for visualization; not lab-grade metrology.",
"",
"Keys",
" O open file P toggle scope Home/End reset / fit view",
" F1 / Esc close this dialog",
};
int n = sizeof(lines) / sizeof(lines[0]);
for (int i = 0; i < n; i++) {
const char* s = lines[i];
bool heading = (s[0] != '\0' && s[0] != ' ');
DrawTextScaled(s, px, py, heading ? 14 : 13, heading ? (Color){ 150, 200, 255, 255 } : LIGHTGRAY);
py += (s[0] == '\0' ? 8 : 18) * scale;
}
DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale,
panel.y + ph - 26 * scale, 12, GRAY);
// Note: opening/closing is handled in the main input loop (not here) so the
// same click/keypress that opens the dialog can't immediately close it.
}