// ui.c - file browser, sidebar controls, sliders, and PNG export #include "ui.h" #include "render.h" #include "stft.h" #include "audio.h" #include "platform.h" #include #include #include #include // Internal sidebar widgets (defined after DrawSidebar, which uses them) static void DrawSlider(Rectangle bounds, float value); static bool UpdateSlider(Rectangle bounds, float* value); // True if the rect was left-clicked this frame (hovered + button pressed). static bool Clicked(Rectangle r) { return CheckCollisionPointRec(GetMousePosition(), r) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); } // The ubiquitous "filled rectangle + 1px border" panel/button chrome. static void DrawPanelBox(Rectangle r, Color fill, Color border) { DrawRectangleRec(r, fill); DrawRectangleLinesEx(r, 1, border); } // ===== File browser ===== void FreeBrowserFiles(void) { if (app.browserFiles) { for (int i = 0; i < app.browserFileCount; i++) free(app.browserFiles[i]); free(app.browserFiles); free(app.browserIsDir); app.browserFiles = NULL; app.browserIsDir = NULL; } app.browserFileCount = 0; } void ScanDirectory(const char* path) { FreeBrowserFiles(); strncpy(app.browserPath, path, sizeof(app.browserPath) - 1); FilePathList files = LoadDirectoryFiles(path); int dirCount = 0, wavCount = 0; for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (DirectoryExists(files.paths[i])) dirCount++; else { const char* ext = GetFileExtension(files.paths[i]); if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) wavCount++; } } int totalCount = dirCount + wavCount; if (totalCount > 0) { app.browserFiles = (char**)malloc(totalCount * sizeof(char*)); app.browserIsDir = (bool*)malloc(totalCount * sizeof(bool)); app.browserFileCount = 0; for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (DirectoryExists(files.paths[i])) { size_t len = strlen(name) + 1; app.browserFiles[app.browserFileCount] = (char*)malloc(len); if (app.browserFiles[app.browserFileCount]) { memcpy(app.browserFiles[app.browserFileCount], name, len); app.browserIsDir[app.browserFileCount] = true; app.browserFileCount++; } } } for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (!DirectoryExists(files.paths[i])) { const char* ext = GetFileExtension(files.paths[i]); if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) { size_t len = strlen(name) + 1; app.browserFiles[app.browserFileCount] = (char*)malloc(len); if (app.browserFiles[app.browserFileCount]) { memcpy(app.browserFiles[app.browserFileCount], name, len); app.browserIsDir[app.browserFileCount] = false; app.browserFileCount++; } } } } } UnloadDirectoryFiles(files); app.browserScroll = 0; app.browserSelected = -1; } static void NavigateToParentDirectory(void) { const char* parent = GetPrevDirectoryPath(app.browserPath); if (parent && strlen(parent) > 0) ScanDirectory(parent); } static void NavigateToDirectory(const char* dirName) { char newPath[512]; int written = snprintf(newPath, sizeof(newPath), "%s/%s", app.browserPath, dirName); if (written < 0 || written >= (int)sizeof(newPath)) return; // Path too long if (DirectoryExists(newPath)) ScanDirectory(newPath); } static void LoadSelectedFile(void) { if (app.browserSelected < 0 || app.browserSelected >= app.browserFileCount) return; char filePath[512]; int written = snprintf(filePath, sizeof(filePath), "%s/%s", app.browserPath, app.browserFiles[app.browserSelected]); if (written < 0 || written >= (int)sizeof(filePath)) return; // Path too long if (app.browserIsDir[app.browserSelected]) { NavigateToDirectory(app.browserFiles[app.browserSelected]); } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) { ResetForNewSignal(); app.showFileBrowser = false; TraceLog(LOG_INFO, "Loaded: %s", filePath); } } void DrawFileBrowser(void) { // Draw semi-transparent overlay first DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f)); float scale = GetUIScale(); float bw = 900.0f * scale, bh = 700.0f * scale; float bx = (GetScreenWidth() - bw) / 2, by = (GetScreenHeight() - bh) / 2; DrawRectangle(bx, by, bw, bh, (Color){ 45, 45, 55, 255 }); DrawRectangleLinesEx((Rectangle){ bx, by, bw, bh }, (int)(2 * scale), GRAY); DrawRectangle(bx, by, bw, (int)(40 * scale), (Color){ 60, 60, 75, 255 }); DrawTextScaled("File Browser - Select WAV File", bx + (int)(15 * scale), by + (int)(8 * scale), (int)(20 * scale), WHITE); // Path bar float pathBarY = by + (int)(46 * scale); DrawRectangle(bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale), (Color){ 30, 30, 40, 255 }); DrawRectangleLinesEx((Rectangle){ bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale) }, (int)(1 * scale), GRAY); char displayPath[300]; strncpy(displayPath, app.browserPath, sizeof(displayPath) - 1); displayPath[sizeof(displayPath) - 1] = '\0'; if (strlen(displayPath) > 60) sprintf(displayPath, "...%s", app.browserPath + strlen(app.browserPath) - 57); DrawTextScaled(displayPath, bx + (int)(22 * scale), pathBarY + (int)(5 * scale), (int)(14 * scale), LIGHTGRAY); // Up button Rectangle upBtn = { bx + bw - (int)(90 * scale), pathBarY, (int)(75 * scale), (int)(30 * scale) }; if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 }); DrawTextScaled("UP (..)", upBtn.x + (int)(10 * scale), upBtn.y + (int)(7 * scale), (int)(14 * scale), WHITE); // File list float lx = bx + (int)(15 * scale), ly = pathBarY + (int)(40 * scale); float lw = bw - (int)(30 * scale), lh = bh - (int)(195 * scale); DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 }); DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, (int)(1 * scale), GRAY); // Line height: base 36px scaled (enough for icon + filename without overlap) float lineH = 36 * scale; // Handle empty directory int visibleItems = (int)(lh / lineH); if (visibleItems < 1) visibleItems = 1; if (app.browserFileCount <= 0 || !app.browserFiles) { DrawTextScaled("(No WAV files in directory)", lx + (int)(20 * scale), ly + (int)(lh / 2 - 12 * scale), (int)(14 * scale), GRAY); } else { if (app.browserFileCount > visibleItems) { float sh = (float)visibleItems / app.browserFileCount * lh; if (sh < (int)(10 * scale)) sh = (int)(10 * scale); float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh); DrawRectangle(lx + lw - (int)(10 * scale), sy, (int)(8 * scale), sh, GRAY); } int startItem = app.browserScroll; int endItem = startItem + visibleItems + 1; if (endItem > app.browserFileCount) endItem = app.browserFileCount; float iconW = (int)(45 * scale); // space for icon column for (int i = startItem; i < endItem; i++) { if (i < 0 || i >= app.browserFileCount || !app.browserFiles[i] || !app.browserIsDir) continue; float iy = ly + (i - startItem) * lineH + (int)(2 * scale); bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + (int)(2 * scale), iy, lw - (int)(14 * scale), lineH - (int)(4 * scale) }); if (i == app.browserSelected) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 50, 70, 120, 180 }); else if (hovered) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 60, 60, 80, 100 }); const char* icon = app.browserIsDir[i] ? "[DIR]" : "[WAV]"; Color iconCol = app.browserIsDir[i] ? (Color){ 255, 220, 80, 255 } : (Color){ 80, 200, 120, 255 }; DrawTextScaled(icon, lx + (int)(8 * scale), iy + (int)(4 * scale), (int)(13 * scale), iconCol); DrawTextScaled(app.browserFiles[i], lx + iconW + (int)(10 * scale), iy + (int)(4 * scale), (int)(14 * scale), WHITE); } } // Scroll with mouse wheel if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && app.browserFileCount > 0) { int wheel = GetMouseWheelMove(); if (wheel > 0) app.browserScroll--; if (wheel < 0) app.browserScroll++; if (app.browserScroll < 0) app.browserScroll = 0; int maxScroll = app.browserFileCount - visibleItems; if (maxScroll < 0) maxScroll = 0; if (app.browserScroll > maxScroll) app.browserScroll = maxScroll; } // Handle clicks if (CheckCollisionPointRec(GetMousePosition(), upBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) NavigateToParentDirectory(); if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) { int clicked = app.browserScroll + (int)((GetMouseY() - ly) / lineH); if (clicked >= 0 && clicked < app.browserFileCount) { app.browserSelected = clicked; static double lastClick = 0; if (GetTime() - lastClick < 0.3) LoadSelectedFile(); lastClick = GetTime(); } } // Buttons float btnY = by + bh - (int)(55 * scale); Rectangle openBtn = { bx + bw - (int)(170 * scale), btnY, (int)(150 * scale), (int)(40 * scale) }; Rectangle cancelBtn = { bx + (int)(15 * scale), btnY, (int)(120 * scale), (int)(40 * scale) }; bool openHovered = CheckCollisionPointRec(GetMousePosition(), openBtn); bool openClicked = openHovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); if (openHovered) DrawRectangleRec(openBtn, (Color){ 100, 100, 120, 255 }); else DrawRectangleRec(openBtn, (Color){ 80, 80, 90, 255 }); DrawRectangleLinesEx(openBtn, (int)(1 * scale), WHITE); DrawTextScaled("OPEN (Enter)", openBtn.x + (int)(25 * scale), openBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 }); DrawTextScaled("ESC Cancel", cancelBtn.x + (int)(18 * scale), cancelBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); if ((IsKeyPressed(KEY_ENTER) || openClicked) && app.browserSelected >= 0 && app.browserFileCount > 0) LoadSelectedFile(); if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false; if (IsKeyPressed(KEY_UP) && app.browserSelected > 0 && app.browserFileCount > 0) { app.browserSelected--; if (app.browserSelected < app.browserScroll) app.browserScroll = app.browserSelected; } if (IsKeyPressed(KEY_DOWN) && app.browserSelected < app.browserFileCount - 1 && app.browserFileCount > 0) { app.browserSelected++; if (app.browserSelected >= app.browserScroll + visibleItems) app.browserScroll = app.browserSelected - visibleItems + 1; } } // ===== PNG export ===== void ExportPNG(const SpectrogramApp* spa, const char* dirPath) { if (!spa->stftComputed || !spa->spectrogramImage.data) return; int imgW = spa->spectrogramImage.width; int imgH = spa->spectrogramImage.height; // Selection region in image-pixel coordinates int selX0 = (int)(spa->sel.timeStart * imgW); int selX1 = (int)(spa->sel.timeEnd * imgW); int selY0 = (int)((1.0f - spa->sel.freqEnd) * imgH); int selY1 = (int)((1.0f - spa->sel.freqStart) * imgH); // Clamp to image bounds selX0 = Clamp(selX0, 0, imgW); selX1 = Clamp(selX1, 0, imgW); selY0 = Clamp(selY0, 0, imgH); selY1 = Clamp(selY1, 0, imgH); int regionW = selX1 - selX0; int regionH = selY1 - selY0; if (regionW <= 0 || regionH <= 0) return; // Extract selected region by copying pixel rows Image region = { 0 }; region.width = regionW; region.height = regionH; region.mipmaps = 1; region.format = spa->spectrogramImage.format; region.data = RL_MALLOC(regionW * regionH * 4); if (!region.data) return; // Raylib stores images as tightly packed RGBA rows - memcpy each row at a time Color* src = (Color*)spa->spectrogramImage.data; Color* dst = (Color*)region.data; for (int y = 0; y < regionH; y++) { memcpy(dst + y * regionW, src + (selY0 + y) * imgW + selX0, regionW * 4); } // Scale if a non-zero exportScale is set (and region isn't too large) if (spa->exportScale > 0.0f && regionW < 4096) { int outW = regionW * (int)spa->exportScale; int outH = regionH * (int)spa->exportScale; if (outW > 0 && outH > 0) { ImageResize(®ion, outW, outH); } } // Export as PNG char path[4096]; if (spa->exportScale <= 0.0f && regionW == imgW) { // Full width export without scaling snprintf(path, sizeof(path), "%s/spectrogram_full.png", dirPath); } else { snprintf(path, sizeof(path), "%s/spectrogram_export.png", dirPath); } if (ExportImage(region, path)) { snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Exported: %dx%d %.40s", region.width, region.height, path); } else { snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed"); } ((SpectrogramApp*)spa)->exportMessageTimer = 3.0f; RL_FREE(region.data); } // ===== Sidebar ===== void DrawSidebar(void) { float scale = GetUIScale(); float sidebarWidth = 300 * scale; float x = 10 * scale; float y = 10 * scale; int fontSize = (int)(12 * scale); bool needsRegen = false; // Dark sidebar background DrawRectangle(0, 0, (int)(sidebarWidth + 20 * scale), GetScreenHeight(), (Color){ 35, 35, 40, 255 }); DrawLine((int)(sidebarWidth + 10 * scale), 0, (int)(sidebarWidth + 10 * scale), GetScreenHeight(), GRAY); // Title DrawTextScaled("Spectrogram Controls", x, y, 16, WHITE); y += 28 * scale; // FFT Size clicker DrawTextScaled(TextFormat("FFT: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, 14, LIGHTGRAY); y += 20 * scale; Rectangle fftMinus = { x, y, 30 * scale, 25 * scale }; Rectangle fftPlus = { x + sidebarWidth - 40 * scale, y, 30 * scale, 25 * scale }; if (Clicked(fftMinus)) { int newFFT = app.fftSize / 2; if (newFFT >= FFT_SIZE_MIN) { ChangeFFTSize(newFFT); } } if (Clicked(fftPlus)) { int newFFT = app.fftSize * 2; if (newFFT <= FFT_SIZE_MAX) { ChangeFFTSize(newFFT); } } DrawPanelBox(fftMinus, (Color){ 50, 50, 60, 255 }, GRAY); DrawTextScaled("-", fftMinus.x + 12 * scale, fftMinus.y + 5 * scale, 18, WHITE); DrawPanelBox(fftPlus, (Color){ 50, 50, 60, 255 }, GRAY); DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE); y += 32 * scale; // Amplitude scale mode toggle (Relative dynamic range vs Absolute dBFS) Rectangle scaleBtn = { x, y, sidebarWidth - 10 * scale, 22 * scale }; if (Clicked(scaleBtn)) { app.amplitudeMode = (app.amplitudeMode == SCALE_RELATIVE) ? SCALE_ABSOLUTE : SCALE_RELATIVE; AutoScaleAmplitude(&app.stft); // re-derive floor/ceiling for the new mode needsRegen = true; } DrawPanelBox(scaleBtn, (Color){ 50, 50, 60, 255 }, 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; Rectangle cmapButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; if (Clicked(cmapButton)) { app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); GenerateColormapTexture(); needsRegen = true; } DrawPanelBox(cmapButton, (Color){ 50, 50, 60, 255 }, GRAY); DrawTextScaled(ColormapName(app.colormap), cmapButton.x + 10 * scale, cmapButton.y + 6 * scale, 14, WHITE); DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, (Rectangle){ cmapButton.x + cmapButton.width - 60 * scale, cmapButton.y + 5 * scale, 50 * scale, 15 * scale }, (Vector2){ 0, 0 }, 0.0f, WHITE); y += 32 * scale; // Grid toggle Rectangle gridCheck = { x, y, 18 * scale, 18 * scale }; if (Clicked(gridCheck)) { app.showGrid = !app.showGrid; } DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE); DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale; // Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel. Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; if (Clicked(markerBtn)) app.markerMode = !app.markerMode; DrawPanelBox(markerBtn, app.markerMode ? (Color){ 110, 70, 30, 255 } : (Color){ 50, 50, 60, 255 }, app.markerMode ? ORANGE : GRAY); DrawTextScaled(app.markerMode ? "Marker Tool: ON (M)" : "Marker Tool (M)", markerBtn.x + 10 * scale, markerBtn.y + 5 * scale, 13, WHITE); y += 28 * scale; Rectangle specBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; if (Clicked(specBtn)) app.showSpectrum = !app.showSpectrum; DrawPanelBox(specBtn, app.showSpectrum ? (Color){ 30, 70, 90, 255 } : (Color){ 50, 50, 60, 255 }, app.showSpectrum ? SKYBLUE : GRAY); DrawTextScaled(app.showSpectrum ? "Spectrum: ON (S)" : "Spectrum Slice (S)", specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE); y += 30 * scale; // File loading DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale; Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; if (Clicked(fileButton)) { app.showFileBrowser = true; ScanDirectory(GetWorkingDirectory()); } DrawPanelBox(fileButton, (Color){ 50, 50, 60, 255 }, GRAY); DrawTextScaled("Open File Browser (O)", fileButton.x + 10 * scale, fileButton.y + 6 * scale, 14, WHITE); y += 38 * scale; // Playback Rectangle playButton = { x, y, sidebarWidth - 10 * scale, 35 * scale }; if (Clicked(playButton)) { if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { StopSound(AudioPlaybackSound); app.isPlaying = false; app.playheadElapsed = 0; app.playheadT = 0; } else { PlaySelectedRegion(); app.isPlaying = true; } } const char* playText = app.isPlaying ? "STOP (SPACE)" : "PLAY (SPACE)"; DrawPanelBox(playButton, app.isPlaying ? (Color){ 120, 40, 40, 255 } : (Color){ 40, 100, 40, 255 }, app.isPlaying ? RED : GREEN); DrawTextScaled(playText, playButton.x + 10 * scale, playButton.y + 12 * scale, 14, WHITE); y += 48 * scale; // Fullscreen toggle Rectangle fsButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; bool isFullscreen = IsWindowFullscreen(); if (Clicked(fsButton)) { ToggleFullscreen(); } DrawPanelBox(fsButton, isFullscreen ? (Color){ 40, 80, 120, 255 } : (Color){ 50, 50, 60, 255 }, GRAY); DrawTextScaled(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", fsButton.x + 10 * scale, fsButton.y + 6 * scale, 14, WHITE); y += 38 * scale; // Clear selection (identical to the Esc key) Rectangle clearButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; if (Clicked(clearButton)) { ClearSelection(); } DrawPanelBox(clearButton, (Color){ 80, 50, 50, 255 }, RED); DrawTextScaled("Clear Selection (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE); y += 38 * scale; // Export PNG { Rectangle exportButton = { x, y, sidebarWidth - 10 * scale, 30 * scale }; if (Clicked(exportButton)) { ExportPNG(&app, app.exportDir); } DrawPanelBox(exportButton, (Color){ 40, 60, 80, 255 }, CYAN); DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE); y += 35 * scale; // Export selection audio (band-limited, time-cropped) as a WAV Rectangle wavButton = { x, y, sidebarWidth - 10 * scale, 30 * scale }; if (Clicked(wavButton)) { ExportSelectionWAV(app.exportDir); } DrawPanelBox(wavButton, (Color){ 40, 60, 80, 255 }, CYAN); DrawTextScaled("Export WAV (W)", wavButton.x + 10 * scale, wavButton.y + 7 * scale, 14, WHITE); y += 35 * scale; // Export scale slider DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale; Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale }; DrawSlider(scaleSlider, app.exportScale / 10.0f); float scaleVal = 0.0f; if (UpdateSlider(scaleSlider, &scaleVal)) { app.exportScale = scaleVal * 10.0f; } y += 24 * scale; } // Signal info DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale; if (app.loaded) { DrawTextScaled(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, 14, GRAY); y += 18 * scale; DrawTextScaled(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, 14, GRAY); y += 18 * scale; DrawTextScaled(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, 14, GRAY); y += 18 * scale; } 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 (Clicked(aboutBtn)) { app.showAbout = true; } DrawPanelBox(aboutBtn, (Color){ 50, 50, 60, 255 }, 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); app.visibleTextureValid = false; // Force cache invalidation } } static void DrawSlider(Rectangle bounds, float value) { // Background DrawRectangleRec(bounds, DARKGRAY); DrawRectangleLinesEx(bounds, 1, GRAY); // 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; } // ===== 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 = 540 * 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.", }; 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; } // Keys — rendered straight from the keymap table so this list can never // drift from the actual bindings. py += 10 * scale; DrawTextScaled("Keys", px, py, 14, (Color){ 150, 200, 255, 255 }); py += 20 * scale; int kn; const KeyBinding* km = GetKeymap(&kn); for (int i = 0; i < kn; i++) { DrawTextScaled(TextFormat(" %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY); py += 16 * scale; } DrawTextScaled(" Mouse wheel = zoom, Alt+drag = pan, drag = select box", px, py, 13, LIGHTGRAY); 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. }