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
This commit is contained in:
2026-05-24 21:28:30 -07:00
parent c03d236230
commit 1cbba956b8
+128 -2
View File
@@ -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(&region, 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();
}