From 1cbba956b8a1aa3a165dd997e2a4cfdc2b8d34a0 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 24 May 2026 21:28:30 -0700 Subject: [PATCH] feat: add PNG export for spectrogram image Add Export PNG button and keyboard shortcut (E) to export the spectrogramImage as a PNG file. Supports: - Cropping to the current time/frequency selection region - Optional upscaling via the export scale slider (0.0x to 10.0x) - Exporting the full unscaled image when no selection and scale is 0 Exports to the app's working directory via raylib's ExportImage. Export status shown as a brief toast notification. Known issues: - Frequency scale doesn't adjust properly when zooming (freqView clamps to [0,1] range but zoom factor isn't frequency-aware) - Hardcoded export filenames; no filename/path picker yet --- src/spectrogram.c | 130 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/src/spectrogram.c b/src/spectrogram.c index 7297f68..4c02685 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -111,6 +111,12 @@ typedef struct { float freqSelectionStart; float freqSelectionEnd; bool isFreqSelecting; + + // Export settings + float exportScale; + char exportDir[4096]; + char exportMessage[256]; + Vector2 selectStartPos; // For minimum drag distance check bool isDraggingSelection; // Dragging existing selection box Vector2 dragSelectionStartPos; // Mouse position when started dragging selection @@ -1209,6 +1215,79 @@ static void DrawSelectionDrag(Rectangle bounds) DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW); } +// ============================================================================ +// PNG Export +// ============================================================================ + +static 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->timeSelectionStart * imgW); + int selX1 = (int)(spa->timeSelectionEnd * imgW); + int selY0 = (int)((1.0f - spa->freqSelectionEnd) * imgH); + int selY1 = (int)((1.0f - spa->freqSelectionStart) * 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"); + } + + RL_FREE(region.data); +} + static void DrawSidebar(void) { float scale = GetUIScale(); @@ -1360,6 +1439,28 @@ static void DrawSidebar(void) DrawTextScaled("Clear (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 (CheckCollisionPointRec(GetMousePosition(), exportButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + ExportPNG(&app, app.exportDir); + } + DrawRectangleRec(exportButton, (Color){ 40, 60, 80, 255 }); + DrawRectangleLinesEx(exportButton, 1, CYAN); + DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.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) { @@ -1420,10 +1521,15 @@ int main(int argc, char* argv[]) // Save original working directory so command-line args resolve correctly // before we change working dir to resources/ - static char originalDir[512] = { 0 }; + static char originalDir[4096] = { 0 }; snprintf(originalDir, sizeof(originalDir), "%s", GetWorkingDirectory()); TraceLog(LOG_INFO, "Original working directory: %s", originalDir); + // Set export directory to the app's working directory (before CWD changes) + snprintf(app.exportDir, sizeof(app.exportDir), "%s", originalDir); + app.exportScale = 1.0f; + app.exportMessage[0] = '\0'; + SearchAndSetResourceDir("resources"); // Load TTF font at a fixed base size. Scaling is handled uniformly by @@ -1464,7 +1570,7 @@ int main(int argc, char* argv[]) bool fileLoaded = false; if (argc > 1) { TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]); - char resolvedPath[4096] = { 0 }; + char resolvedPath[8192] = { 0 }; // If the path doesn't exist as-is, try prepending original dir if (!FileExists(argv[1]) && originalDir[0]) { @@ -1700,6 +1806,11 @@ int main(int argc, char* argv[]) } } + // Export PNG + if (IsKeyPressed(KEY_E) && !app.showFileBrowser && app.stftComputed) { + ExportPNG(&app, app.exportDir); + } + if (IsKeyPressed(KEY_ESCAPE)) { if (app.showFileBrowser) { app.showFileBrowser = false; @@ -2097,6 +2208,21 @@ int main(int argc, char* argv[]) // Draw file browser on top (if active) if (app.showFileBrowser) DrawFileBrowser(); + // Export message notification + if (app.exportMessage[0] != '\0') { + int msgW = MeasureText(app.exportMessage, 20); + int boxW = msgW + 40; + int boxH = 36; + int boxX = GetScreenWidth() / 2 - boxW / 2; + int boxY = 15; + DrawRectangle(boxX, boxY, boxW, boxH, (Color){ 30, 30, 30, 220 }); + DrawRectangleLines(boxX, boxY, boxW, boxH, CYAN); + DrawText(app.exportMessage, boxX + (boxW - msgW) / 2, boxY + 10, 20, WHITE); + } + + // Reset export message after one frame display + app.exportMessage[0] = '\0'; + EndDrawing(); }