From b4c23d61a8efcd8da0739e6a324bf3afdfc4fa70 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 11 Apr 2026 21:49:26 -0700 Subject: [PATCH] Add frequency zoom, scrollbars, dynamic layout that fills window Co-authored-by: Qwen-Coder --- src/spectrogram.c | 258 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 204 insertions(+), 54 deletions(-) diff --git a/src/spectrogram.c b/src/spectrogram.c index e3f62af..a0d5e6c 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -27,7 +27,9 @@ // Configuration // ============================================================================ -#define FFT_SIZE 128 +#define FFT_SIZE_DEFAULT 2048 +#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 @@ -96,17 +98,23 @@ typedef struct { bool isFreqSelecting; // Viewport/zoom controls - float viewStart; // 0-1, start of visible region - float viewEnd; // 0-1, end of visible region + float viewStart; // 0-1, start of visible time region + float viewEnd; // 0-1, end of visible time region + float freqViewStart; // 0-1, start of visible frequency region (0 = 0Hz) + float freqViewEnd; // 0-1, end of visible frequency region (1 = Nyquist) bool isPanning; float panStartViewStart; float panStartViewEnd; + float panStartFreqViewStart; + float panStartFreqViewEnd; Vector2 panStartPos; // Cached visible texture Texture2D visibleTexture; int cachedVisibleStart; int cachedVisibleEnd; + int cachedVisibleStartY; + int cachedVisibleEndY; bool visibleTextureValid; // Display settings @@ -114,6 +122,7 @@ typedef struct { float amplitudeCeilingDb; ColormapType colormap; bool showGrid; + int fftSize; // Current FFT size (128-2048) // File browser state bool showFileBrowser; @@ -969,7 +978,27 @@ static void DrawSidebar(void) // Title DrawText("Spectrogram Controls", x, y, 14, WHITE); y += 25; - + + // FFT Size clicker + DrawText(TextFormat("FFT Size: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, fontSize, LIGHTGRAY); y += 18; + Rectangle fftMinus = { x, y, 30, 25 }; + Rectangle fftPlus = { x + sidebarWidth - 40, y, 30, 25 }; + if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + int newFFT = app.fftSize / 2; + if (newFFT >= FFT_SIZE_MIN) { app.fftSize = newFFT; app.stftComputed = false; needsRegen = true; } + } + if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + int newFFT = app.fftSize * 2; + if (newFFT <= FFT_SIZE_MAX) { app.fftSize = newFFT; app.stftComputed = false; needsRegen = true; } + } + DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(fftMinus, 1, GRAY); + DrawText("-", fftMinus.x + 12, fftMinus.y + 5, 16, WHITE); + DrawRectangleRec(fftPlus, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(fftPlus, 1, GRAY); + DrawText("+", fftPlus.x + 10, fftPlus.y + 5, 16, WHITE); + y += 30; + // dB Floor slider DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, fontSize, LIGHTGRAY); y += 18; Rectangle dbSlider = { x, y, sidebarWidth - 10, 20 }; @@ -980,7 +1009,7 @@ static void DrawSidebar(void) needsRegen = true; // Trigger immediate redraw } y += 25; - + // Colormap dropdown DrawText("Colormap:", x, y, fontSize, LIGHTGRAY); y += 18; const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" }; @@ -993,11 +1022,11 @@ static void DrawSidebar(void) 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 }, + 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)) { @@ -1065,7 +1094,7 @@ static void DrawSidebar(void) if (app.loaded) { 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("FFT: %d, %.1f Hz/bin (synchrosqueezed)", FFT_SIZE, (float)app.signal.sampleRate / FFT_SIZE), 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 file loaded", x, y, fontSize, GRAY); y += 16; } @@ -1121,6 +1150,7 @@ int main(int argc, char* argv[]) app.timeSelectionStart = 0.0f; app.timeSelectionEnd = 1.0f; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; app.viewStart = 0.0f; app.viewEnd = 1.0f; + app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f; app.showGrid = true; app.colormap = COLORMAP_INFERNO; app.amplitudeFloorDb = -60.0f; @@ -1130,7 +1160,10 @@ int main(int argc, char* argv[]) app.visibleTexture = (Texture2D){ 0 }; app.cachedVisibleStart = -1; app.cachedVisibleEnd = -1; + app.cachedVisibleStartY = -1; + app.cachedVisibleEndY = -1; app.visibleTextureValid = false; + app.fftSize = FFT_SIZE_DEFAULT; app.isPlaying = false; app.playbackFinished = false; @@ -1197,64 +1230,91 @@ int main(int argc, char* argv[]) // View controls if (app.loaded && !app.showFileBrowser) { - // Spectrogram area is to the right of sidebar (320px) - Rectangle viewBounds = { 330, 50, GetScreenWidth() - 380, GetScreenHeight() - 150 }; + // Spectrogram area fills remaining window space + // Leave room for scrollbars (20px) and axis labels + float sidebarWidth = 320; + float scrollbarSize = 18; + float labelMarginLeft = 65; + float labelMarginBottom = 35; + float topMargin = 50; + + Rectangle viewBounds = { + sidebarWidth + labelMarginLeft, + topMargin, + GetScreenWidth() - sidebarWidth - labelMarginLeft - scrollbarSize - 10, + GetScreenHeight() - topMargin - labelMarginBottom - scrollbarSize - 10 + }; - // Zoom with mouse wheel (zooms to cursor position) - if (GetMousePosition().x > 320 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) { + // Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio) + if (GetMousePosition().x > 380 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) { int wheel = GetMouseWheelMove(); if (wheel != 0) { - // Calculate mouse position as normalized time (0-1) - float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width; - mouseT = Clamp(mouseT + app.viewStart, 0.0f, 1.0f); - - float viewWidth = app.viewEnd - app.viewStart; float zoomFactor = (wheel > 0) ? 0.8f : 1.2f; + + // --- Time axis zoom (around cursor X) --- + float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width; + mouseT = app.viewStart + mouseT * (app.viewEnd - app.viewStart); + float viewWidth = app.viewEnd - app.viewStart; float newWidth = viewWidth * zoomFactor; if (newWidth < 0.02f) newWidth = 0.02f; if (newWidth > 1.0f) newWidth = 1.0f; - - // Zoom around cursor position float leftOfMouse = mouseT - app.viewStart; float rightOfMouse = app.viewEnd - mouseT; - - float newLeftOfMouse = leftOfMouse * (newWidth / viewWidth); - float newRightOfMouse = rightOfMouse * (newWidth / viewWidth); - - app.viewStart = mouseT - newLeftOfMouse; - app.viewEnd = mouseT + newRightOfMouse; - - // Clamp to valid range + app.viewStart = mouseT - leftOfMouse * (newWidth / viewWidth); + app.viewEnd = mouseT + rightOfMouse * (newWidth / viewWidth); if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = newWidth; } if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - newWidth; } + // --- Frequency axis zoom (around cursor Y) --- + float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height; + mouseF = app.freqViewStart + mouseF * (app.freqViewEnd - app.freqViewStart); + float freqWidth = app.freqViewEnd - app.freqViewStart; + float newFreqWidth = freqWidth * zoomFactor; + if (newFreqWidth < 0.02f) newFreqWidth = 0.02f; + if (newFreqWidth > 1.0f) newFreqWidth = 1.0f; + float belowMouse = mouseF - app.freqViewStart; + float aboveMouse = app.freqViewEnd - mouseF; + app.freqViewStart = mouseF - belowMouse * (newFreqWidth / freqWidth); + app.freqViewEnd = mouseF + aboveMouse * (newFreqWidth / freqWidth); + if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = newFreqWidth; } + if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - newFreqWidth; } + // Invalidate texture cache app.visibleTextureValid = false; } } - // Pan with Alt+drag or middle mouse button + // Pan with Alt+drag or middle mouse button (pans both axes) bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.isPanning = true; app.panStartPos = GetMousePosition(); app.panStartViewStart = app.viewStart; app.panStartViewEnd = app.viewEnd; + app.panStartFreqViewStart = app.freqViewStart; + app.panStartFreqViewEnd = app.freqViewEnd; } if (app.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { float dx = (GetMousePosition().x - app.panStartPos.x) / viewBounds.width; + float dy = (GetMousePosition().y - app.panStartPos.y) / viewBounds.height; float viewWidth = app.panStartViewEnd - app.panStartViewStart; + float freqWidth = app.panStartFreqViewEnd - app.panStartFreqViewStart; app.viewStart = app.panStartViewStart - dx * viewWidth; app.viewEnd = app.panStartViewEnd - dx * viewWidth; if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; } if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; } + app.freqViewStart = app.panStartFreqViewStart + dy * freqWidth; + app.freqViewEnd = app.panStartFreqViewEnd + dy * freqWidth; + if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = freqWidth; } + if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - freqWidth; } app.visibleTextureValid = false; } if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.isPanning = false; // Home/End keys - if (IsKeyPressed(KEY_HOME)) { - app.viewStart = 0.0f; app.viewEnd = 1.0f; + if (IsKeyPressed(KEY_HOME)) { + app.viewStart = 0.0f; app.viewEnd = 1.0f; + app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f; app.visibleTextureValid = false; } if (IsKeyPressed(KEY_END)) { @@ -1297,10 +1357,21 @@ int main(int argc, char* argv[]) } // Selection (only when mouse is in spectrogram area, not sidebar) - Rectangle selBounds = { 330, 50, GetScreenWidth() - 380, GetScreenHeight() - 150 }; + float selSidebarWidth = 320; + float selScrollbarSize = 18; + float selLabelMarginLeft = 65; + float selLabelMarginBottom = 35; + float selTopMargin = 50; + + Rectangle selBounds = { + selSidebarWidth + selLabelMarginLeft, + selTopMargin, + GetScreenWidth() - selSidebarWidth - selLabelMarginLeft - selScrollbarSize - 10, + GetScreenHeight() - selTopMargin - selLabelMarginBottom - selScrollbarSize - 10 + }; Vector2 mousePos = GetMousePosition(); - if (app.loaded && !app.showFileBrowser && mousePos.x > 320 && CheckCollisionPointRec(mousePos, selBounds)) { + if (app.loaded && !app.showFileBrowser && mousePos.x > 380 && CheckCollisionPointRec(mousePos, selBounds)) { bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && !app.isPanning) { @@ -1348,7 +1419,7 @@ int main(int argc, char* argv[]) if (app.loaded && !app.stftComputed) { TraceLog(LOG_INFO, "Computing STFT..."); double startTime = GetTime(); - ComputeSTFT(&app.signal, &app.stft, FFT_SIZE); + ComputeSTFT(&app.signal, &app.stft, app.fftSize); TraceLog(LOG_INFO, "STFT computed in %.2f sec (%d segments)", GetTime() - startTime, app.stft.numSegments); GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); app.stftComputed = true; @@ -1358,61 +1429,140 @@ int main(int argc, char* argv[]) BeginDrawing(); ClearBackground((Color){ 30, 30, 30, 255 }); - // Spectrogram area is to the right of sidebar (320px) - Rectangle viewBounds = { 330, 50, GetScreenWidth() - 380, GetScreenHeight() - 150 }; + // Spectrogram area fills remaining window space + // Sidebar: 320px wide, spectrogram takes the rest + // Leave room for scrollbars (20px) and axis labels + float sidebarWidth = 320; + float scrollbarSize = 18; + float labelMarginLeft = 65; + float labelMarginBottom = 35; + float topMargin = 50; + + Rectangle viewBounds = { + sidebarWidth + labelMarginLeft, + topMargin, + GetScreenWidth() - sidebarWidth - labelMarginLeft - scrollbarSize - 10, + GetScreenHeight() - topMargin - labelMarginBottom - scrollbarSize - 10 + }; + Rectangle hScrollbar = { viewBounds.x, viewBounds.y + viewBounds.height + 10, viewBounds.width, scrollbarSize }; + Rectangle vScrollbar = { viewBounds.x + viewBounds.width + 10, viewBounds.y, scrollbarSize, viewBounds.height }; // Draw sidebar first (on top left) DrawSidebar(); // Draw spectrogram (background, in its own area) if (app.loaded && app.stftComputed) { + int imgWidth = app.spectrogramImage.width; + int imgHeight = app.spectrogramImage.height; + + // Calculate visible region (time and frequency) + int visibleStartX = (int)(app.viewStart * imgWidth); + int visibleEndX = (int)(app.viewEnd * imgWidth); + int visibleWidth = visibleEndX - visibleStartX; - // Calculate visible region - int visibleStart = (int)(app.viewStart * app.spectrogramImage.width); - int visibleEnd = (int)(app.viewEnd * app.spectrogramImage.width); - int visibleWidth = visibleEnd - visibleStart; + // Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max) + int visibleStartY = (int)((1.0f - app.freqViewEnd) * imgHeight); + int visibleEndY = (int)((1.0f - app.freqViewStart) * imgHeight); + int visibleHeight = visibleEndY - visibleStartY; // Invalidate cache if view changed or texture not valid - bool cacheInvalid = !app.visibleTextureValid || - visibleStart != app.cachedVisibleStart || - visibleEnd != app.cachedVisibleEnd || - visibleWidth <= 0; - - if (cacheInvalid && visibleWidth > 0 && visibleStart >= 0) { + bool cacheInvalid = !app.visibleTextureValid || + visibleStartX != app.cachedVisibleStart || + visibleEndX != app.cachedVisibleEnd || + visibleStartY != app.cachedVisibleStartY || + visibleEndY != app.cachedVisibleEndY || + visibleWidth <= 0 || visibleHeight <= 0; + + if (cacheInvalid && visibleWidth > 0 && visibleStartX >= 0 && visibleHeight > 0 && visibleStartY >= 0) { // Free old texture if exists if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); - + // Create a sub-image for the visible region - Image visibleImage = GenImageColor(visibleWidth, app.spectrogramImage.height, BLACK); + Image visibleImage = GenImageColor(visibleWidth, visibleHeight, BLACK); Color* srcPixels = (Color*)app.spectrogramImage.data; Color* dstPixels = (Color*)visibleImage.data; - for (int y = 0; y < app.spectrogramImage.height; y++) { + for (int y = 0; y < visibleHeight; y++) { for (int x = 0; x < visibleWidth; x++) { - dstPixels[y * visibleWidth + x] = srcPixels[y * app.spectrogramImage.width + visibleStart + x]; + dstPixels[y * visibleWidth + x] = srcPixels[(visibleStartY + y) * imgWidth + visibleStartX + x]; } } app.visibleTexture = LoadTextureFromImage(visibleImage); UnloadImage(visibleImage); - - app.cachedVisibleStart = visibleStart; - app.cachedVisibleEnd = visibleEnd; + + app.cachedVisibleStart = visibleStartX; + app.cachedVisibleEnd = visibleEndX; + app.cachedVisibleStartY = visibleStartY; + app.cachedVisibleEndY = visibleEndY; app.visibleTextureValid = true; } // Draw cached texture if (app.visibleTextureValid && app.visibleTexture.id != 0) { DrawTexturePro(app.visibleTexture, - (Rectangle){ 0, 0, visibleWidth, app.spectrogramImage.height }, + (Rectangle){ 0, 0, visibleWidth, visibleHeight }, viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE); } + // Draw scrollbars + // Horizontal scrollbar (time) + DrawRectangleRec(hScrollbar, DARKGRAY); + float hThumbWidth = (app.viewEnd - app.viewStart) * hScrollbar.width; + float hThumbX = hScrollbar.x + app.viewStart * hScrollbar.width; + if (hThumbWidth < 10) hThumbWidth = 10; + DrawRectangle(hThumbX, hScrollbar.y, hThumbWidth, hScrollbar.height, GRAY); + + // Vertical scrollbar (frequency) + DrawRectangleRec(vScrollbar, DARKGRAY); + float vThumbHeight = (app.freqViewEnd - app.freqViewStart) * vScrollbar.height; + float vThumbY = vScrollbar.y + (1.0f - app.freqViewEnd) * vScrollbar.height; + if (vThumbHeight < 10) vThumbHeight = 10; + DrawRectangle(vScrollbar.x, vThumbY, vScrollbar.width, vThumbHeight, GRAY); + + // Handle scrollbar dragging + static bool draggingH = false, draggingV = false; + static Vector2 dragStartPos; + static float dragStartViewStart, dragStartFreqViewStart; + + if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), hScrollbar)) { + draggingH = true; + dragStartPos = GetMousePosition(); + dragStartViewStart = app.viewStart; + } + if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), vScrollbar)) { + draggingV = true; + dragStartPos = GetMousePosition(); + dragStartFreqViewStart = app.freqViewStart; + } + if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width; + float viewWidth = app.viewEnd - app.viewStart; + app.viewStart = dragStartViewStart + dx; + app.viewEnd = app.viewStart + viewWidth; + if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; } + if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; } + app.visibleTextureValid = false; + } + if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height; + float freqWidth = app.freqViewEnd - app.freqViewStart; + app.freqViewStart = dragStartFreqViewStart - dy; + app.freqViewEnd = app.freqViewStart + freqWidth; + if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = freqWidth; } + if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - freqWidth; } + app.visibleTextureValid = false; + } + if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; } + if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f)); DrawSelection(viewBounds); DrawLabels(viewBounds); DrawText("Spectrogram", viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY); - DrawText(TextFormat("Frequency (Hz) - Max: %d", app.signal.sampleRate / 2), + float maxFreq = (float)app.signal.sampleRate / 2.0f; + float freqMin = app.freqViewStart * maxFreq; + float freqMax = app.freqViewEnd * maxFreq; + DrawText(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax), viewBounds.x - 50, viewBounds.y + viewBounds.height / 2 - 10, 14, Fade(LIGHTGRAY, 0.7f)); } else if (!app.showFileBrowser) { const char* msg1 = "Press 'O' or click 'Open File Browser' to load a WAV";