diff --git a/src/render.c b/src/render.c index aab2fe5..6f38a4a 100644 --- a/src/render.c +++ b/src/render.c @@ -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); } diff --git a/src/spectrogram.c b/src/spectrogram.c index 4fe94cb..0bd7250 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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()); } @@ -215,7 +217,12 @@ int main(int argc, char* argv[]) if (IsKeyPressed(KEY_P)) { 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); diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index 5ac7652..c937924 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -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]; diff --git a/src/stft.c b/src/stft.c index e8c16de..14bca63 100644 --- a/src/stft.c +++ b/src/stft.c @@ -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; } diff --git a/src/ui.c b/src/ui.c index 8de738b..46ea501 100644 --- a/src/ui.c +++ b/src/ui.c @@ -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. +} diff --git a/src/ui.h b/src/ui.h index 18be09c..6f47409 100644 --- a/src/ui.h +++ b/src/ui.h @@ -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