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:
+7
-1
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user