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:
+128
-2
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user