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 freqSelectionStart;
|
||||||
float freqSelectionEnd;
|
float freqSelectionEnd;
|
||||||
bool isFreqSelecting;
|
bool isFreqSelecting;
|
||||||
|
|
||||||
|
// Export settings
|
||||||
|
float exportScale;
|
||||||
|
char exportDir[4096];
|
||||||
|
char exportMessage[256];
|
||||||
|
|
||||||
Vector2 selectStartPos; // For minimum drag distance check
|
Vector2 selectStartPos; // For minimum drag distance check
|
||||||
bool isDraggingSelection; // Dragging existing selection box
|
bool isDraggingSelection; // Dragging existing selection box
|
||||||
Vector2 dragSelectionStartPos; // Mouse position when started dragging selection
|
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);
|
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)
|
static void DrawSidebar(void)
|
||||||
{
|
{
|
||||||
float scale = GetUIScale();
|
float scale = GetUIScale();
|
||||||
@@ -1360,6 +1439,28 @@ static void DrawSidebar(void)
|
|||||||
DrawTextScaled("Clear (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE);
|
DrawTextScaled("Clear (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE);
|
||||||
y += 38 * scale;
|
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
|
// Signal info
|
||||||
DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||||
if (app.loaded) {
|
if (app.loaded) {
|
||||||
@@ -1420,10 +1521,15 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
// Save original working directory so command-line args resolve correctly
|
// Save original working directory so command-line args resolve correctly
|
||||||
// before we change working dir to resources/
|
// before we change working dir to resources/
|
||||||
static char originalDir[512] = { 0 };
|
static char originalDir[4096] = { 0 };
|
||||||
snprintf(originalDir, sizeof(originalDir), "%s", GetWorkingDirectory());
|
snprintf(originalDir, sizeof(originalDir), "%s", GetWorkingDirectory());
|
||||||
TraceLog(LOG_INFO, "Original working directory: %s", originalDir);
|
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");
|
SearchAndSetResourceDir("resources");
|
||||||
|
|
||||||
// Load TTF font at a fixed base size. Scaling is handled uniformly by
|
// 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;
|
bool fileLoaded = false;
|
||||||
if (argc > 1) {
|
if (argc > 1) {
|
||||||
TraceLog(LOG_INFO, "Loading file from command line: %s", argv[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 the path doesn't exist as-is, try prepending original dir
|
||||||
if (!FileExists(argv[1]) && originalDir[0]) {
|
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 (IsKeyPressed(KEY_ESCAPE)) {
|
||||||
if (app.showFileBrowser) {
|
if (app.showFileBrowser) {
|
||||||
app.showFileBrowser = false;
|
app.showFileBrowser = false;
|
||||||
@@ -2097,6 +2208,21 @@ int main(int argc, char* argv[])
|
|||||||
// Draw file browser on top (if active)
|
// Draw file browser on top (if active)
|
||||||
if (app.showFileBrowser) DrawFileBrowser();
|
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();
|
EndDrawing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user