From 87183cbe7d77aad093ba68468ed01d4a275a6297 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 30 Mar 2026 20:05:57 -0700 Subject: [PATCH] Add sidebar UI with sliders, buttons, checkboxes. ESC clears selections instead of exiting Co-authored-by: Qwen-Coder --- src/spectrogram.c | 268 +++++++++++++++++++++++++++++----------------- 1 file changed, 170 insertions(+), 98 deletions(-) diff --git a/src/spectrogram.c b/src/spectrogram.c index 16c8d33..c226aa6 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -28,8 +28,8 @@ // ============================================================================ #define FFT_SIZE_DEFAULT 2048 -#define FFT_SIZE_MAX 8192 -#define FFT_SIZE_MIN 512 +#define FFT_SIZE_MAX 2048 +#define FFT_SIZE_MIN 128 #define HOP_RATIO 4 // FFT_SIZE / HOP_SIZE = 4 means 75% overlap #define MAX_SAMPLE_RATE 48000 #define LOUDNESS_FLOOR_DB -80.0f @@ -786,74 +786,187 @@ static void DrawLabels(Rectangle bounds) DrawText("Freq", bounds.x - 35, bounds.y + bounds.height / 2, fontSize, GRAY); } +static void DrawSlider(Rectangle bounds, float value); +static bool UpdateSlider(Rectangle bounds, float* value); +static void DrawSidebar(void); + static void DrawSelection(Rectangle bounds) { Color overlayColor = Fade(BLACK, 0.5f); float selStartX = bounds.x + app.timeSelectionStart * bounds.width; float selEndX = bounds.x + app.timeSelectionEnd * bounds.width; - + if (selStartX > bounds.x) DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor); if (selEndX < bounds.x + bounds.width) DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor); - + float selStartY = bounds.y + bounds.height - app.freqSelectionEnd * bounds.height; float selEndY = bounds.y + bounds.height - app.freqSelectionStart * bounds.height; - + if (selStartY > bounds.y) DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor); if (selEndY < bounds.y + bounds.height) DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor); - + DrawLineV((Vector2){ selStartX, bounds.y }, (Vector2){ selStartX, bounds.y + bounds.height }, YELLOW); DrawLineV((Vector2){ selEndX, bounds.y }, (Vector2){ selEndX, bounds.y + bounds.height }, YELLOW); - + if (app.freqSelectionStart > 0.0f || app.freqSelectionEnd < 1.0f) { DrawLineV((Vector2){ selStartX, selStartY }, (Vector2){ selEndX, selStartY }, CYAN); DrawLineV((Vector2){ selStartX, selEndY }, (Vector2){ selEndX, selEndY }, CYAN); } } -static void DrawInfo(Rectangle bounds) +static void DrawSidebar(void) { - int fontSize = 12, y = 10; + float sidebarWidth = 280; + float x = 10; + float y = 10; + int fontSize = 12; + bool needsRegen = false; + // Title + DrawText("Spectrogram Controls", x, y, 14, WHITE); y += 25; + + // FFT Size slider + DrawText(TextFormat("FFT Size: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, fontSize, LIGHTGRAY); y += 18; + Rectangle fftSlider = { x, y, sidebarWidth - 10, 20 }; + float fftValue = (float)(app.fftSize - FFT_SIZE_MIN) / (FFT_SIZE_MAX - FFT_SIZE_MIN); + DrawSlider(fftSlider, fftValue); + if (UpdateSlider(fftSlider, &fftValue)) { + int newFFT = FFT_SIZE_MIN + (int)(fftValue * (FFT_SIZE_MAX - FFT_SIZE_MIN)); + // Round to power of 2 + while ((newFFT & (newFFT - 1)) != 0) newFFT++; + if (newFFT != app.fftSize && newFFT >= FFT_SIZE_MIN && newFFT <= FFT_SIZE_MAX) { + app.fftSize = newFFT; + app.stftComputed = false; + needsRegen = true; + } + } + y += 25; + + // dB Floor slider + DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, fontSize, LIGHTGRAY); y += 18; + Rectangle dbSlider = { x, y, sidebarWidth - 10, 20 }; + float dbValue = (app.amplitudeFloorDb + 100.0f) / 80.0f; + DrawSlider(dbSlider, dbValue); + if (UpdateSlider(dbSlider, &dbValue)) { + app.amplitudeFloorDb = -100.0f + dbValue * 80.0f; + needsRegen = true; + } + y += 25; + + // Colormap dropdown + DrawText("Colormap:", x, y, fontSize, LIGHTGRAY); y += 18; + const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" }; + Rectangle cmapButton = { x, y, sidebarWidth - 10, 25 }; + if (CheckCollisionPointRec(GetMousePosition(), cmapButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); + GenerateColormapTexture(); + needsRegen = true; + } + DrawRectangleRec(cmapButton, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(cmapButton, 1, GRAY); + DrawText(colormapNames[app.colormap], cmapButton.x + 10, cmapButton.y + 6, fontSize, WHITE); + DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, + (Rectangle){ cmapButton.x + cmapButton.width - 60, cmapButton.y + 5, 50, 15 }, + (Vector2){ 0, 0 }, 0.0f, WHITE); + y += 30; + + // Grid toggle + Rectangle gridCheck = { x, y, 18, 18 }; + if (CheckCollisionPointRec(GetMousePosition(), gridCheck) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.showGrid = !app.showGrid; + } + DrawRectangleRec(gridCheck, app.showGrid ? BLUE : DARKGRAY); + DrawRectangleLinesEx(gridCheck, 1, WHITE); + DrawText("Show Grid", x + 25, y + 2, fontSize, LIGHTGRAY); y += 25; + + // File loading + DrawText("File:", x, y, fontSize, LIGHTGRAY); y += 18; + Rectangle fileButton = { x, y, sidebarWidth - 10, 25 }; + if (CheckCollisionPointRec(GetMousePosition(), fileButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.showFileBrowser = true; + ScanDirectory(GetWorkingDirectory()); + } + DrawRectangleRec(fileButton, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(fileButton, 1, GRAY); + DrawText("Open File Browser (O)", fileButton.x + 10, fileButton.y + 6, fontSize, WHITE); + y += 35; + + // Playback + Rectangle playButton = { x, y, sidebarWidth - 10, 35 }; + if (CheckCollisionPointRec(GetMousePosition(), playButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + PlaySelectedRegion(); + } + DrawRectangleRec(playButton, (Color){ 40, 100, 40, 255 }); + DrawRectangleLinesEx(playButton, 1, GREEN); + DrawText("PLAY Selection (SPACE)", playButton.x + 10, playButton.y + 12, fontSize, WHITE); + y += 45; + + // Reset/Clear buttons + Rectangle resetButton = { x, y, (sidebarWidth - 15) / 2, 25 }; + if (CheckCollisionPointRec(GetMousePosition(), resetButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.timeSelectionStart = app.viewStart; + app.timeSelectionEnd = app.viewEnd; + app.freqSelectionStart = 0.0f; + app.freqSelectionEnd = 1.0f; + } + DrawRectangleRec(resetButton, (Color){ 80, 50, 50, 255 }); + DrawRectangleLinesEx(resetButton, 1, RED); + DrawText("Reset Sel (R)", resetButton.x + 10, resetButton.y + 6, fontSize, WHITE); + + Rectangle clearButton = { x + resetButton.width + 5, y, (sidebarWidth - 15) / 2, 25 }; + if (CheckCollisionPointRec(GetMousePosition(), clearButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.timeSelectionStart = 0.0f; + app.timeSelectionEnd = 1.0f; + app.freqSelectionStart = 0.0f; + app.freqSelectionEnd = 1.0f; + } + DrawRectangleRec(clearButton, (Color){ 80, 50, 50, 255 }); + DrawRectangleLinesEx(clearButton, 1, RED); + DrawText("Clear (ESC)", clearButton.x + 10, clearButton.y + 6, fontSize, WHITE); + y += 35; + + // Signal info + DrawText("Signal Info:", x, y, fontSize, LIGHTGRAY); y += 18; if (app.loaded) { - DrawText(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), 10, y, fontSize, LIGHTGRAY); y += 20; - DrawText(TextFormat("Duration: %.2f sec", app.signal.duration), 10, y, fontSize, LIGHTGRAY); y += 20; - DrawText(TextFormat("View: %.1f%%-%.1f%% (%.2f sec)", app.viewStart*100, app.viewEnd*100, (app.viewEnd-app.viewStart)*app.signal.duration), 10, y, fontSize, LIGHTGRAY); y += 20; - DrawText(TextFormat("FFT: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), 10, y, fontSize, LIGHTGRAY); y += 20; - DrawText(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), 10, y, fontSize, LIGHTGRAY); y += 20; - y += 10; - - float selDuration = (app.timeSelectionEnd - app.timeSelectionStart) * app.signal.duration; - DrawText(TextFormat("Time Sel: %.2f-%.2f sec (%.3f sec)", app.timeSelectionStart*app.signal.duration, app.timeSelectionEnd*app.signal.duration, selDuration), 10, y, fontSize, YELLOW); y += 20; - - float maxFreq = (float)app.signal.sampleRate / 2.0f; - DrawText(TextFormat("Freq Sel: %.0f-%.0f Hz", app.freqSelectionStart*maxFreq, app.freqSelectionEnd*maxFreq), 10, y, fontSize, CYAN); + DrawText(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, fontSize, GRAY); y += 16; + DrawText(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, fontSize, GRAY); y += 16; + DrawText(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, fontSize, GRAY); y += 16; } else { - DrawText("No audio file loaded", 10, y, fontSize, RED); + DrawText("No file loaded", x, y, fontSize, GRAY); y += 16; } - y = GetScreenHeight() - 280; - DrawText("Controls:", 10, y, fontSize, LIGHTGRAY); y += 20; - DrawText(" O - Open file browser", 10, y, fontSize, GRAY); y += 18; - DrawText(" Drag & drop WAV file", 10, y, fontSize, GRAY); y += 18; - DrawText(" Mouse Wheel - Zoom to cursor", 10, y, fontSize, GRAY); y += 18; - DrawText(" Alt/Middle+Drag - Pan view", 10, y, fontSize, GRAY); y += 18; - DrawText(" LMB Drag - Select time region", 10, y, fontSize, GRAY); y += 18; - DrawText(" Shift+LMB - Select freq range", 10, y, fontSize, GRAY); y += 18; - DrawText(" SPACE - Play selection", 10, y, fontSize, GRAY); y += 18; - DrawText(" 1/2 - FFT size (resolution)", 10, y, fontSize, GRAY); y += 18; - DrawText(" M - Cycle colormap", 10, y, fontSize, GRAY); y += 18; - DrawText(" W/S - Adjust dB floor", 10, y, fontSize, GRAY); y += 18; - DrawText(" G - Toggle grid", 10, y, fontSize, GRAY); y += 18; - DrawText(" R - Reset selections", 10, y, fontSize, GRAY); y += 18; - DrawText(" Home - Full view, End - Zoom in", 10, y, fontSize, GRAY); y += 18; - DrawText(" ESC - Clear selections", 10, y, fontSize, GRAY); + if (needsRegen && app.stftComputed) { + GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); + } +} + +static void DrawSlider(Rectangle bounds, float value) +{ + // Background + DrawRectangleRec(bounds, DARKGRAY); + DrawRectangleLinesEx(bounds, 1, GRAY); - y = GetScreenHeight() - 60; - DrawText("Colormap:", GetScreenWidth() - 200, y, fontSize, LIGHTGRAY); - const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" }; - DrawText(colormapNames[app.colormap], GetScreenWidth() - 200, y + 18, fontSize, WHITE); - DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, (Rectangle){ GetScreenWidth() - 100, y + 15, 80, 15 }, (Vector2){ 0, 0 }, 0.0f, WHITE); - DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), GetScreenWidth() - 200, y + 35, fontSize, LIGHTGRAY); + // Fill + float fillWidth = (bounds.width - 4) * value; + DrawRectangle(bounds.x + 2, bounds.y + 2, fillWidth, bounds.height - 4, BLUE); + + // Handle + float handleX = bounds.x + 2 + fillWidth; + DrawRectangle(handleX - 3, bounds.y + 1, 6, bounds.height - 2, WHITE); +} + +static bool UpdateSlider(Rectangle bounds, float* value) +{ + bool changed = false; + if (CheckCollisionPointRec(GetMousePosition(), bounds) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float newValue = (GetMousePosition().x - bounds.x - 2) / (bounds.width - 4); + newValue = Clamp(newValue, 0.0f, 1.0f); + if (fabsf(newValue - *value) > 0.001f) { + *value = newValue; + changed = true; + } + } + return changed; } // ============================================================================ @@ -1004,59 +1117,16 @@ int main(int argc, char* argv[]) } } - // Other controls - if (IsKeyPressed(KEY_G) && !app.showFileBrowser) { - app.showGrid = !app.showGrid; - } - if (IsKeyPressed(KEY_R) && !app.showFileBrowser) { - app.timeSelectionStart = app.viewStart; - app.timeSelectionEnd = app.viewEnd; - app.freqSelectionStart = 0.0f; - app.freqSelectionEnd = 1.0f; - } - if (IsKeyPressed(KEY_M) && !app.showFileBrowser) { - app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); - GenerateColormapTexture(); - if (app.stftComputed) GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); - app.visibleTextureValid = false; - } - if (IsKeyPressed(KEY_W) && !app.showFileBrowser) { - app.amplitudeFloorDb += 2.0f; - if (app.amplitudeFloorDb > app.amplitudeCeilingDb - 5) app.amplitudeFloorDb = app.amplitudeCeilingDb - 5; - if (app.stftComputed) GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); - app.visibleTextureValid = false; - } - if (IsKeyPressed(KEY_S) && !app.showFileBrowser) { - app.amplitudeFloorDb -= 2.0f; - if (app.amplitudeFloorDb < -100) app.amplitudeFloorDb = -100; - if (app.stftComputed) GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); - app.visibleTextureValid = false; - } + // Keyboard shortcuts (SPACE for play, ESC for clear) if (IsKeyPressed(KEY_SPACE) && !app.showFileBrowser) PlaySelectedRegion(); - - // FFT size control (1/2 keys) - if (IsKeyPressed(KEY_ONE) && !app.showFileBrowser) { - app.fftSize /= 2; - if (app.fftSize < FFT_SIZE_MIN) app.fftSize = FFT_SIZE_MIN; - else { - app.stftComputed = false; - TraceLog(LOG_INFO, "FFT size: %d", app.fftSize); - } - } - if (IsKeyPressed(KEY_TWO) && !app.showFileBrowser) { - app.fftSize *= 2; - if (app.fftSize > FFT_SIZE_MAX) app.fftSize = FFT_SIZE_MAX; - else { - app.stftComputed = false; - TraceLog(LOG_INFO, "FFT size: %d", app.fftSize); - } - } - + if (IsKeyPressed(KEY_ESCAPE)) { - if (app.showFileBrowser) app.showFileBrowser = false; - else { - app.timeSelectionStart = app.viewStart; - app.timeSelectionEnd = app.viewEnd; + if (app.showFileBrowser) { + app.showFileBrowser = false; + } else { + // Clear selections instead of exiting + app.timeSelectionStart = 0.0f; + app.timeSelectionEnd = 1.0f; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; } @@ -1188,9 +1258,11 @@ int main(int argc, char* argv[]) // Draw file browser on top (if active) if (app.showFileBrowser) DrawFileBrowser(); - // Draw UI info and FPS (always on top) - DrawInfo(viewBounds); - DrawFPS(GetScreenWidth() - 100, 10); + // Draw sidebar controls + DrawSidebar(); + + // Draw FPS + DrawFPS(GetScreenWidth() - 80, 10); EndDrawing(); }