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
+7 -1
View File
@@ -200,10 +200,16 @@ void ColorizeSpectrogram(Image* image, Texture2D* texture)
*image = GenImageColor(width, height, BLACK);
Color* pixels = (Color*)image->data;
// Guard the range: if the floor reaches/exceeds the ceiling the division
// would otherwise go to ~0 (everything clamps white) or negative (the
// colors invert). A >=1 dB range degrades gracefully to a hard threshold.
float range = app.amplitudeCeilingDb - app.amplitudeFloorDb;
if (range < 1.0f) range = 1.0f;
for (int i = 0; i < width * height; i++) {
if (accumBuffer[i] > 0.0001f) {
float db = AmplitudeToDecibels(accumBuffer[i]);
float normalized = (db - app.amplitudeFloorDb) / (app.amplitudeCeilingDb - app.amplitudeFloorDb);
float normalized = (db - app.amplitudeFloorDb) / range;
normalized = Clamp(normalized, 0.0f, 1.0f);
pixels[i] = GetColormapColor(normalized, app.colormap);
}
+27 -8
View File
@@ -68,7 +68,6 @@ void ResetForNewSignal(void)
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
app.amplitudeUserSet = false; // re-enable auto-scaling for the new file
// Cached STFT results are tied to the old signal data.
FreeAllCacheEntries(&app.fftCache);
@@ -124,6 +123,9 @@ int main(int argc, char* argv[])
app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f;
app.showGrid = true;
app.colormap = COLORMAP_INFERNO;
app.amplitudeMode = SCALE_RELATIVE;
app.dynRangeDb = 40.0f; // relative: show 40 dB below the peak
app.absoluteFloorDb = -60.0f; // absolute: -60 dBFS floor
app.amplitudeFloorDb = -60.0f;
app.amplitudeCeilingDb = 0.0f;
app.showFileBrowser = false;
@@ -206,7 +208,7 @@ int main(int argc, char* argv[])
}
// File browser toggle
if (IsKeyPressed(KEY_O) && !app.showFileBrowser) {
if (IsKeyPressed(KEY_O) && !app.showFileBrowser && !app.showAbout) {
app.showFileBrowser = true;
ScanDirectory(GetWorkingDirectory());
}
@@ -216,6 +218,11 @@ int main(int argc, char* argv[])
app.showScope = !app.showScope;
}
// About / help dialog (F1)
if (IsKeyPressed(KEY_F1)) {
app.showAbout = !app.showAbout;
}
// File browser update
if (app.showFileBrowser) {
// Browser handles its own input
@@ -242,7 +249,7 @@ int main(int argc, char* argv[])
}
// View controls
if (app.loaded && !app.showFileBrowser) {
if (app.loaded && !app.showFileBrowser && !app.showAbout) {
// Spectrogram area fills remaining window space (scaled)
float viewScale = GetUIScale();
float sidebarWidth = 320 * viewScale;
@@ -422,7 +429,7 @@ int main(int argc, char* argv[])
}
// Keyboard shortcuts (SPACE for play/stop toggle, ESC for clear)
if (IsKeyPressed(KEY_SPACE) && !app.showFileBrowser) {
if (IsKeyPressed(KEY_SPACE) && !app.showFileBrowser && !app.showAbout) {
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Currently playing - stop it
StopSound(AudioPlaybackSound);
@@ -443,12 +450,14 @@ int main(int argc, char* argv[])
}
// Export PNG
if (IsKeyPressed(KEY_E) && !app.showFileBrowser && app.stftComputed) {
if (IsKeyPressed(KEY_E) && !app.showFileBrowser && !app.showAbout && app.stftComputed) {
ExportPNG(&app, app.exportDir);
}
if (IsKeyPressed(KEY_ESCAPE)) {
if (app.showFileBrowser) {
if (app.showAbout) {
app.showAbout = false;
} else if (app.showFileBrowser) {
app.showFileBrowser = false;
} else {
// Clear selections instead of exiting
@@ -522,7 +531,7 @@ int main(int argc, char* argv[])
}
// LMB drag = box select (time + frequency) OR drag existing selection
if (app.loaded && !app.showFileBrowser && CheckCollisionPointRec(mousePos, selBounds)) {
if (app.loaded && !app.showFileBrowser && !app.showAbout && CheckCollisionPointRec(mousePos, selBounds)) {
// Set cursor to resize all when near divider
if (mouseNearDivider && !app.isDividing) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL);
@@ -620,7 +629,7 @@ int main(int argc, char* argv[])
}
// Handle divider drag
if (app.loaded && app.showScope && !app.showFileBrowser) {
if (app.loaded && app.showScope && !app.showFileBrowser && !app.showAbout) {
// Calculate divider position (using same calculation as selBounds)
float viewScale = GetUIScale();
float topMargin = 50 * viewScale;
@@ -736,6 +745,13 @@ int main(int argc, char* argv[])
}
}
// Dismiss the About dialog with a click. Handled here, after the
// spectrogram input above (which is gated off while it's open), so the
// dismissing click can't fall through and start a selection/pan.
if (app.showAbout && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.showAbout = false;
}
// Rendering
BeginDrawing();
ClearBackground((Color){ 30, 30, 30, 255 });
@@ -959,6 +975,9 @@ int main(int argc, char* argv[])
// Draw file browser on top (if active)
if (app.showFileBrowser) DrawFileBrowser();
// About / help dialog (topmost)
DrawAboutDialog();
// Export message notification
if (app.exportMessage[0] != '\0') {
int msgW = MeasureText(app.exportMessage, 20);
+16 -2
View File
@@ -49,6 +49,14 @@ typedef enum {
COLORMAP_COUNT
} ColormapType;
// How the colorizer maps amplitude to brightness:
// - RELATIVE: ceiling tracks the signal peak; floor sits dynRangeDb below it.
// - ABSOLUTE: fixed dBFS scale (0 dBFS ceiling = full scale, absolute floor).
typedef enum {
SCALE_RELATIVE = 0,
SCALE_ABSOLUTE
} AmplitudeScaleMode;
typedef struct {
float frequency;
float amplitude;
@@ -136,10 +144,13 @@ typedef struct {
int cachedVisibleEndY;
bool visibleTextureValid;
// Display settings
// Display settings. amplitudeFloorDb/CeilingDb are the values the colorizer
// actually uses; they're derived from the mode + the controls below.
float amplitudeFloorDb;
float amplitudeCeilingDb;
bool amplitudeUserSet; // true once the user adjusts the dB floor; suppresses auto-scale
AmplitudeScaleMode amplitudeMode;
float dynRangeDb; // RELATIVE mode: dB of range shown below the peak
float absoluteFloorDb; // ABSOLUTE mode: floor in dBFS (ceiling pinned at 0)
ColormapType colormap;
bool showGrid;
int fftSize; // Current FFT size (128-2048)
@@ -150,6 +161,9 @@ typedef struct {
int reassignWidth;
int reassignHeight;
// Overlays
bool showAbout; // About / help dialog
// File browser state
bool showFileBrowser;
char browserPath[512];
+13 -8
View File
@@ -369,23 +369,28 @@ int ComputeSkipFactor(float signalDurationSec)
// This replaces existing overview (skipFactor-strided) segments with
// ===== Amplitude auto-scaling =====
// Derive the colorizer's floor/ceiling from the current scale mode and controls.
// Called after the STFT changes (load, background completion, FFT-size change)
// and when the user toggles the mode. Deriving the floor from dynRangeDb /
// absoluteFloorDb here is what preserves the user's setting across re-scales.
void AutoScaleAmplitude(StftResult* stft)
{
// Don't clobber a dB floor the user has set by hand. Reset on new file load
// re-enables auto-scaling (see ResetForNewSignal).
if (app.amplitudeUserSet) return;
if (app.amplitudeMode == SCALE_ABSOLUTE) {
// Fixed dBFS scale: 0 dBFS (full-scale) ceiling, user-set absolute floor.
app.amplitudeCeilingDb = 0.0f;
app.amplitudeFloorDb = app.absoluteFloorDb;
return;
}
// Relative: ceiling tracks the signal peak; floor sits dynRangeDb below it.
float maxDb = -999.0f;
float minDb = 0.0f;
for (int seg = 0; seg < stft->numSegments; seg++) {
for (int bin = 0; bin < stft->segments[seg].numBins; bin++) {
float db = AmplitudeToDecibels(stft->segments[seg].spectrum[bin].amplitude);
if (db > maxDb) maxDb = db;
if (db < minDb) minDb = db;
}
}
// Set ceiling at the max, floor 40dB below — enough range to see structure
// but not so wide that the signal is drowned in black
if (maxDb < -998.0f) maxDb = 0.0f; // no data yet — sane default
app.amplitudeCeilingDb = maxDb;
app.amplitudeFloorDb = maxDb - 40.0f;
app.amplitudeFloorDb = maxDb - app.dynRangeDb;
}
+106 -9
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,6 +515,17 @@ 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.
@@ -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.
}
+3
View File
@@ -15,4 +15,7 @@ void DrawSidebar(void);
// --- PNG export ---
void ExportPNG(const SpectrogramApp* spa, const char* dirPath);
// --- About / help dialog ---
void DrawAboutDialog(void);
#endif // UI_H