Files
rspektrum/src/spectrogram.c
T
tyler 970d11e60e fix: new-file load resets full nav state (zoom out both axes, stop playback)
ResetForNewSignal only reset the time view + selection, leaving the
previous file's frequency zoom, in-progress drags, and playback running.
Loading a new file now zooms out both axes, cancels any drag/pan/divide,
clears the selection, and stops playback + rewinds the playhead. Display
preferences (colormap, dB scale, FFT size, grid, scope layout) are
preserved by design. All three load paths funnel through this function.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:36:36 -07:00

1086 lines
52 KiB
C

// spectrogram.c - Spectrogram viewer: app entry point and main frame loop.
// Subsystems live in fft/stft/audio/render/ui; shared state in spectrogram_types.h.
#include "raylib.h"
#include "resource_dir.h"
#include "spectrogram_types.h"
#include "fft.h"
#include "stft.h"
#include "audio.h"
#include "render.h"
#include "ui.h"
#include "platform.h"
#include "utils.h"
#include "primitives.h"
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <complex.h>
#include <stdbool.h>
#include <stdio.h>
// ============================================================================
// Global State (declared extern in spectrogram_types.h)
// ============================================================================
SpectrogramApp app = {0};
Sound AudioPlaybackSound = {0};
Texture2D colormapTexture = {0};
Font mainFont = {0}; // TTF font for crisp text at any scale
// ============================================================================
// Interaction Detection
// ============================================================================
/**
* Returns true if the user has pressed any mouse/keyboard input this frame.
* Used to gate background processing — we only compute when the user is idle.
*/
static bool IsUserInteracting(void)
{
if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) ||
IsMouseButtonDown(MOUSE_BUTTON_RIGHT) ||
IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) {
return true;
}
// Check for mouse wheel
if (GetMouseWheelMove() != 0) return true;
// Check for key press (key codes are 0..512 in raylib)
for (int key = 0; key < 512; key++) {
if (IsKeyPressed(key)) return true;
}
return false;
}
// Fraction of the view area used by the spectrogram. When the scope is hidden
// the spectrogram fills the whole area (divider at the bottom); otherwise the
// scope takes the remainder below dividerY.
#define SCOPE_COLLAPSE_DIVIDER 0.88f // drag the handle past this to hide the scope
static float ScopeDivider(void)
{
return app.showScope ? app.dividerY : 1.0f;
}
// Screen layout metrics, derived from window size + UI scale. Single source of
// truth: the input, selection, and render passes all unpack from this so the
// layout formulas live in exactly one place.
typedef struct {
float scale;
float sidebarWidth;
float labelHeight;
float scrollbarHeight;
float freqLabelWidth;
float vScrollbarWidth;
float topMargin;
float bottomMargin;
float spectroHeight; // height of the spectrogram (respects the scope divider)
Rectangle viewBounds; // the spectrogram drawing area
} Layout;
static Layout ComputeLayout(void)
{
Layout L;
L.scale = GetUIScale();
L.sidebarWidth = 320 * L.scale;
L.labelHeight = 15 * L.scale;
L.scrollbarHeight = 18 * L.scale;
L.freqLabelWidth = 65 * L.scale;
L.vScrollbarWidth = 18 * L.scale;
L.topMargin = 50 * L.scale;
L.bottomMargin = 10 * L.scale;
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
L.viewBounds = (Rectangle){
L.sidebarWidth + L.freqLabelWidth,
L.topMargin,
GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale,
L.spectroHeight
};
return L;
}
// Reset all per-signal state after a new signal has been loaded into app.signal.
// Drops the cached STFT/FFT-size cache and the on-screen textures so the main
// loop recomputes from scratch (loadingPhase 0 handles the STFT (re)alloc).
void ResetForNewSignal(void)
{
app.loaded = true;
app.stftComputed = false;
app.loadingPhase = 0;
app.loadingProgress = 0.0f;
app.currentSTFTSegment = 0;
app.skipFactor = 1;
app.highResFinished = false;
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
// Cached STFT results are tied to the old signal data.
FreeAllCacheEntries(&app.fftCache);
// Zoom out both axes and drop the old selection / any in-progress drags.
// Display preferences (colormap, dB scale, FFT size, grid, scope layout)
// are intentionally preserved across loads.
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.view.isPanning = false;
ClearSelection();
app.sel.isDragging = false;
app.sel.isTimeSelecting = false;
app.sel.isFreqSelecting = false;
app.isDividing = false;
// Stop any playback from the previous signal and rewind the playhead.
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) StopSound(AudioPlaybackSound);
app.isPlaying = false;
app.playbackFinished = false;
app.playheadElapsed = 0.0f;
app.playheadT = 0.0f;
// Invalidate the cached visible texture.
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
}
// ============================================================================
// Keymap — handlers + table + dispatcher. See spectrogram_types.h for the
// KeyBinding contract. Adding a global key = add one row here (and, if it needs
// to run at a specific point in the frame, leave action NULL and wire it inline).
// ============================================================================
static void ActionOpenBrowser(void) { app.showFileBrowser = true; ScanDirectory(GetWorkingDirectory()); }
static void ActionToggleScope(void) { app.showScope = !app.showScope; }
static void ActionToggleAbout(void) { app.showAbout = !app.showAbout; }
static void ActionToggleFullscreen(void){ ToggleFullscreen(); }
static void ActionExport(void) { ExportPNG(&app, app.exportDir); }
static void ActionResetView(void)
{
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
}
static void ActionZoomToStart(void)
{
app.view.start = 0.0f;
app.view.end = 0.1f;
app.visibleTextureValid = false;
}
static const KeyBinding KEYMAP[] = {
{ KEY_O, KEYGATE_MODAL, ActionOpenBrowser, "O", "open file browser" },
{ KEY_P, KEYGATE_NONE, ActionToggleScope, "P", "show / hide waveform scope" },
{ KEY_F1, KEYGATE_NONE, ActionToggleAbout, "F1", "about / help" },
{ KEY_F11, KEYGATE_NONE, ActionToggleFullscreen,"F11", "toggle fullscreen" },
{ KEY_HOME, KEYGATE_MODAL | KEYGATE_LOADED,ActionResetView, "Home", "reset view (fit all)" },
{ KEY_END, KEYGATE_MODAL | KEYGATE_LOADED,ActionZoomToStart, "End", "zoom to start" },
{ KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" },
// Order-sensitive: handled inline (see main loop), listed here for the overlay.
{ KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" },
{ KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" },
};
const KeyBinding* GetKeymap(int* count)
{
*count = (int)(sizeof(KEYMAP) / sizeof(KEYMAP[0]));
return KEYMAP;
}
// Run every gated, dispatchable binding whose key was pressed this frame.
static void DispatchKeymap(void)
{
int n;
const KeyBinding* km = GetKeymap(&n);
for (int i = 0; i < n; i++) {
const KeyBinding* b = &km[i];
if (!b->action) continue;
if ((b->gate & KEYGATE_MODAL) && UiModalOpen()) continue;
if ((b->gate & KEYGATE_LOADED) && !app.loaded) continue;
if ((b->gate & KEYGATE_STFT) && !app.stftComputed) continue;
if (IsKeyPressed(b->key)) b->action();
}
}
// ============================================================================
// Main Application
// ============================================================================
int main(int argc, char* argv[])
{
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
InitWindow(1280, 800, "Spectrogram Viewer");
SetTargetFPS(60);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
InitAudioDevice();
SetExitKey(KEY_NULL); // ESC should not close the window
// Save original working directory so command-line args resolve correctly
// before we change working dir to resources/
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
// GetUIScale() for both layout and DrawTextScaled(), so the font scales
// naturally with the window size on any DPI monitor.
mainFont = LoadFontEx("fonts/DejaVuSansMono.ttf", 16, 0, 0);
if (mainFont.texture.id == 0) {
TraceLog(LOG_WARNING, "Failed to load TTF font, using default bitmap font");
}
app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f;
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.showGrid = true;
app.colormap = COLORMAP_INFERNO;
app.amplitudeMode = SCALE_RELATIVE;
app.dynRangeDb = 40.0f; // relative: show 40 dB below the peak
app.absoluteFloorDb = -60.0f; // absolute: -60 dBFS floor
app.amplitudeFloorDb = -60.0f;
app.amplitudeCeilingDb = 0.0f;
app.showFileBrowser = false;
app.isBrowsing = false;
app.visibleTexture = (Texture2D){ 0 };
app.cachedVisibleStart = -1;
app.cachedVisibleEnd = -1;
app.cachedVisibleStartY = -1;
app.cachedVisibleEndY = -1;
app.visibleTextureValid = false;
app.fftSize = FFT_SIZE_DEFAULT;
app.skipFactor = 1;
app.highResFinished = false;
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
// Initialize FFT cache
app.fftCache.count = 0;
app.fftCache.nextOrder = 0;
for (int i = 0; i < FFT_CACHE_SIZE; i++) {
app.fftCache.entries[i].fftSize = 0;
app.fftCache.entries[i].result.numSegments = 0;
app.fftCache.entries[i].result.segments = NULL;
app.fftCache.entries[i].accessOrder = 0;
}
app.isPlaying = false;
app.playbackFinished = false;
app.showScope = true;
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
app.isDividing = false;
app.dividerStartPos = (Vector2){ 0, 0 };
app.dividerStartY = 0;
// Initialize scope view (data synced in render loop when signal loads)
InitScopeView(&app.scopeView,
(WaveformData){app.signal.samples, app.signal.numSamples, app.signal.sampleRate},
0, 0, GetScreenWidth(), 200);
GenerateColormapTexture();
ScanDirectory(GetWorkingDirectory());
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
bool fileLoaded = false;
if (argc > 1) {
TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]);
char resolvedPath[8192] = { 0 };
// If the path doesn't exist as-is, try prepending original dir
if (!FileExists(argv[1]) && originalDir[0]) {
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, argv[1]);
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
}
const char* pathToLoad = FileExists(argv[1]) ? argv[1] : resolvedPath;
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
fileLoaded = true;
ResetForNewSignal();
TraceLog(LOG_INFO, "File loaded successfully");
}
}
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
while (!WindowShouldClose())
{
// Drag & Drop
if (IsFileDropped()) {
FilePathList dropped = LoadDroppedFiles();
if (dropped.count > 0) {
const char* ext = GetFileExtension(dropped.paths[0]);
bool isWav = ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0);
if (isWav && FileExists(dropped.paths[0])) {
if (LoadWavFile(dropped.paths[0], &app.signal)) {
ResetForNewSignal();
}
}
}
UnloadDroppedFiles(dropped);
}
// Global key bindings (table-driven; see KEYMAP/GetKeymap). The
// order-sensitive keys (Space, Esc) are handled inline further below.
DispatchKeymap();
// Check if playback finished naturally
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Check if sound stopped playing (IsSoundPlaying returns false when done)
if (!IsSoundPlaying(AudioPlaybackSound)) {
app.isPlaying = false;
app.playbackFinished = true;
}
// Track playhead position manually
app.playheadElapsed += GetFrameTime();
float selectionDuration = (app.sel.timeEnd - app.sel.timeStart) * app.signal.duration;
if (selectionDuration > 0) {
app.playheadT = app.playheadElapsed / selectionDuration;
}
}
// Handle window resize
if (IsWindowResized()) {
app.visibleTextureValid = false;
}
// View controls
if (app.loaded && !UiModalOpen()) {
// Spectrogram area fills remaining window space (scaled)
Layout L = ComputeLayout();
float viewScale = L.scale;
float sidebarWidth = L.sidebarWidth;
float labelHeight = L.labelHeight;
float scrollbarHeight = L.scrollbarHeight;
float freqLabelWidth = L.freqLabelWidth;
float vScrollbarWidth = L.vScrollbarWidth;
float topMargin = L.topMargin;
float bottomMargin = L.bottomMargin;
float spectroHeight = L.spectroHeight;
Rectangle viewBounds = L.viewBounds;
// Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio)
if (GetMousePosition().x > sidebarWidth + 5 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
int wheel = GetMouseWheelMove();
if (wheel != 0) {
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
// --- Time axis zoom (around cursor X) ---
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
mouseT = app.view.start + mouseT * (app.view.end - app.view.start);
float viewWidth = app.view.end - app.view.start;
float newWidth = viewWidth * zoomFactor;
if (newWidth < 0.02f) newWidth = 0.02f;
if (newWidth > 1.0f) newWidth = 1.0f;
float leftOfMouse = mouseT - app.view.start;
float rightOfMouse = app.view.end - mouseT;
app.view.start = mouseT - leftOfMouse * (newWidth / viewWidth);
app.view.end = mouseT + rightOfMouse * (newWidth / viewWidth);
if (app.view.start < 0) { app.view.start = 0; app.view.end = newWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - newWidth; }
// --- Frequency axis zoom (around cursor Y) ---
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
mouseF = app.view.freqStart + mouseF * (app.view.freqEnd - app.view.freqStart);
float freqWidth = app.view.freqEnd - app.view.freqStart;
float newFreqWidth = freqWidth * zoomFactor;
if (newFreqWidth < 0.001f) newFreqWidth = 0.001f;
float belowMouse = mouseF - app.view.freqStart;
float aboveMouse = app.view.freqEnd - mouseF;
app.view.freqStart = mouseF - belowMouse * (newFreqWidth / freqWidth);
app.view.freqEnd = mouseF + aboveMouse * (newFreqWidth / freqWidth);
// Clamp to physical frequency limits [0, 1] — can't see beyond Nyquist or below 0 Hz
if (app.view.freqStart < 0) { app.view.freqStart = 0; app.view.freqEnd = fminf(app.view.freqEnd, 1.0f); }
if (app.view.freqEnd > 1) { app.view.freqEnd = 1; app.view.freqStart = fmaxf(app.view.freqStart, 0.0f); }
// Invalidate texture cache
app.visibleTextureValid = false;
}
}
// Pan with Alt+drag or middle mouse button (pans both axes)
bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.view.isPanning = true;
app.view.panStartPos = GetMousePosition();
app.view.panStart = app.view.start;
app.view.panEnd = app.view.end;
app.view.panFreqStart = app.view.freqStart;
app.view.panFreqEnd = app.view.freqEnd;
}
if (app.view.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - app.view.panStartPos.x) / viewBounds.width;
float dy = (GetMousePosition().y - app.view.panStartPos.y) / viewBounds.height;
float viewWidth = app.view.panEnd - app.view.panStart;
float freqWidth = app.view.panFreqEnd - app.view.panFreqStart;
app.view.start = app.view.panStart - dx * viewWidth;
app.view.end = app.view.panEnd - dx * viewWidth;
if (app.view.start < 0) { app.view.start = 0; app.view.end = viewWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - viewWidth; }
app.view.freqStart = app.view.panFreqStart + dy * freqWidth;
app.view.freqEnd = app.view.panFreqEnd + dy * freqWidth;
// Clamp to physical limits [0, 1]
if (app.view.freqStart < 0) {
float actualWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqStart = 0;
app.view.freqEnd = fminf(actualWidth, 1.0f);
}
if (app.view.freqEnd > 1) {
float actualWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqEnd = 1;
app.view.freqStart = fmaxf(1.0f - actualWidth, 0.0f);
}
if (app.view.freqStart < 0) app.view.freqStart = 0;
if (app.view.freqEnd > 1) app.view.freqEnd = 1;
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.view.isPanning = false;
// Foreground high-res: when user zooms in, compute missing
// segments in the visible range immediately (responsive).
// Background task handles the rest when idle.
if (app.skipFactor > 1 && app.stft.numSegments > 0 && !app.bgFinished) {
float viewRange = app.view.end - app.view.start;
if (viewRange <= 0.25f) {
// Clamp to valid segment range
int viewStartSeg = (int)(app.view.start * app.stft.numSegments);
int viewEndSeg = (int)(app.view.end * app.stft.numSegments);
if (viewStartSeg < 0) viewStartSeg = 0;
if (viewStartSeg >= app.stft.numSegments) viewStartSeg = app.stft.numSegments - 1;
if (viewEndSeg >= app.stft.numSegments) viewEndSeg = app.stft.numSegments - 1;
// Find first missing segment in the visible range and compute it
for (int seg = viewStartSeg; seg <= viewEndSeg && seg < app.stft.numSegments; seg++) {
if (app.stft.segments[seg].spectrum == NULL) {
int startSeg = seg;
int endSeg = seg + 50;
if (endSeg > viewEndSeg + 1) endSeg = viewEndSeg + 1;
app.bgHighResSeg = ComputeNextHighResChunk(&app.signal, &app.stft, app.fftSize, startSeg, endSeg);
app.visibleTextureValid = false;
TraceLog(LOG_INFO, "Foreground high-res (%d to %d)", startSeg, endSeg - 1);
break;
}
}
}
}
// Background high-res: when user is idle, fill in remaining
// segments at full resolution. Pauses on any interaction.
// Also kicks in when zoomed out (no foreground trigger) to fill
// segments outside the view range.
bool isZoomedIn = (app.skipFactor > 1 && app.view.end - app.view.start <= 0.25f);
if (app.isBgProcessing && !app.bgFinished && !IsUserInteracting()) {
int endSeg = app.bgHighResSeg + 50; // chunks of 50 segments
if (endSeg > app.stft.numSegments) endSeg = app.stft.numSegments;
app.bgHighResSeg = ComputeNextHighResChunk(&app.signal, &app.stft, app.fftSize, app.bgHighResSeg, endSeg);
if (app.bgHighResSeg >= app.stft.numSegments) {
// All done — generate full-res texture and mark complete
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.visibleTextureValid = false;
app.bgFinished = true;
app.isBgProcessing = false;
TraceLog(LOG_INFO, "Background high-res complete (%d segments)", app.stft.numSegments);
// Save the full-res result to cache (overwrites the overview-only entry)
SaveToCache();
}
}
if (app.isBgProcessing && IsUserInteracting()) {
// Pause background processing — user is interacting
app.isBgProcessing = false;
}
// If not zoomed in, scan for missing segments to kick off processing
if (!isZoomedIn && app.isBgProcessing && !app.bgFinished && app.bgHighResSeg < app.stft.numSegments) {
bool hasMissing = false;
for (int i = app.bgHighResSeg; i < app.stft.numSegments; i++) {
if (app.stft.segments[i].spectrum == NULL) { hasMissing = true; break; }
}
if (!hasMissing) {
// No more missing segments — mark complete
app.bgFinished = true;
app.isBgProcessing = false;
SaveToCache();
}
}
}
// Keyboard shortcuts (SPACE for play/stop toggle, ESC for clear)
if (IsKeyPressed(KEY_SPACE) && !UiModalOpen()) {
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Currently playing - stop it
StopSound(AudioPlaybackSound);
app.isPlaying = false;
app.playbackFinished = false;
app.playheadElapsed = 0;
app.playheadT = 0;
} else if (app.playbackFinished) {
// Playback finished naturally - restart from beginning
PlaySelectedRegion();
app.isPlaying = true;
app.playbackFinished = false;
} else {
// Not playing and didn't just finish - start playback
PlaySelectedRegion();
app.isPlaying = true;
}
}
if (IsKeyPressed(KEY_ESCAPE)) {
if (app.showAbout) {
app.showAbout = false;
} else if (app.showFileBrowser) {
app.showFileBrowser = false;
} else {
// Clear selections instead of exiting
ClearSelection();
}
}
// Selection: box select with LMB drag, right-click to clear
Layout selL = ComputeLayout();
float selScale = selL.scale;
float selSidebarWidth = selL.sidebarWidth;
float selLabelHeight = selL.labelHeight;
float selScrollbarHeight = selL.scrollbarHeight;
float selFreqLabelWidth = selL.freqLabelWidth;
float selVScrollbarWidth = selL.vScrollbarWidth;
float selTopMargin = selL.topMargin;
float selBottomMargin = selL.bottomMargin;
float selSpectroHeight = selL.spectroHeight;
Rectangle selBounds = selL.viewBounds;
Vector2 mousePos = GetMousePosition();
// Calculate divider screen position (for hover detection)
float dividerScreenY = selTopMargin + selSpectroHeight;
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
// Right-click clears selection
if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) {
ClearSelection();
}
// Check if click is inside existing selection (for dragging)
bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
bool clickInsideSelection = false;
bool hoverInsideSelection = false;
if (hasSelection && CheckCollisionPointRec(mousePos, selBounds)) {
// Convert mouse position to signal coordinates
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float mouseTime = app.view.start + ((mousePos.x - selBounds.x) / selBounds.width) * viewWidth;
float mouseFreq = app.view.freqStart + (1.0f - (mousePos.y - selBounds.y) / selBounds.height) * freqWidth;
if (mouseTime >= app.sel.timeStart && mouseTime <= app.sel.timeEnd &&
mouseFreq >= app.sel.freqStart && mouseFreq <= app.sel.freqEnd) {
hoverInsideSelection = true;
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
clickInsideSelection = true;
}
}
}
// Set cursor based on context
if (app.sel.isDragging) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging
} else if (hoverInsideSelection) {
SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover
} else {
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
}
// LMB drag = box select (time + frequency) OR drag existing selection
if (app.loaded && !UiModalOpen() && CheckCollisionPointRec(mousePos, selBounds)) {
// Set cursor to resize all when near divider
if (mouseNearDivider && !app.isDividing) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL);
} else if (app.sel.isDragging) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging
} else if (hoverInsideSelection) {
SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover
} else {
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
}
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
if (clickInsideSelection) {
// Start dragging existing selection
app.sel.isDragging = true;
app.sel.dragStartPos = mousePos;
app.sel.dragTimeStart = app.sel.timeStart;
app.sel.dragFreqStart = app.sel.freqStart;
} else {
// Start new box selection
app.sel.isTimeSelecting = true;
app.sel.isFreqSelecting = true;
app.sel.selectStartPos = mousePos;
// Convert screen position to signal coordinates (accounting for zoom)
float viewportT = (mousePos.x - selBounds.x) / selBounds.width;
float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.sel.timeStart = Clamp(app.view.start + viewportT * (app.view.end - app.view.start), 0.0f, 1.0f);
app.sel.timeEnd = app.sel.timeStart;
app.sel.freqStart = Clamp(app.view.freqStart + viewportF * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
app.sel.freqEnd = app.sel.freqStart;
}
}
// Dragging existing selection
if (app.sel.isDragging && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float dx = (mousePos.x - app.sel.dragStartPos.x) / selBounds.width;
float dy = (mousePos.y - app.sel.dragStartPos.y) / selBounds.height;
float timeShift = dx * viewWidth;
float freqShift = -dy * freqWidth; // Y is inverted
float timeWidth = app.sel.timeEnd - app.sel.timeStart;
float freqHeight = app.sel.freqEnd - app.sel.freqStart;
app.sel.timeStart = Clamp(app.sel.dragTimeStart + timeShift, 0.0f, 1.0f - timeWidth);
app.sel.timeEnd = app.sel.timeStart + timeWidth;
app.sel.freqStart = Clamp(app.sel.dragFreqStart + freqShift, 0.0f, 1.0f - freqHeight);
app.sel.freqEnd = app.sel.freqStart + freqHeight;
}
// Creating new box selection
if ((app.sel.isTimeSelecting || app.sel.isFreqSelecting) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float viewportT = (mousePos.x - selBounds.x) / selBounds.width;
float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.sel.timeEnd = Clamp(app.view.start + viewportT * (app.view.end - app.view.start), 0.0f, 1.0f);
app.sel.freqEnd = Clamp(app.view.freqStart + viewportF * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
if (app.sel.isDragging) {
app.sel.isDragging = false;
} else if (app.sel.isTimeSelecting || app.sel.isFreqSelecting) {
// Check if drag was large enough (minimum 5 pixels)
float dx = mousePos.x - app.sel.selectStartPos.x;
float dy = mousePos.y - app.sel.selectStartPos.y;
float dragDist = sqrtf(dx * dx + dy * dy);
if (dragDist > 5.0f) {
// Normalize so start < end
if (app.sel.timeEnd < app.sel.timeStart) {
float tmp = app.sel.timeStart;
app.sel.timeStart = app.sel.timeEnd;
app.sel.timeEnd = tmp;
}
if (app.sel.freqEnd < app.sel.freqStart) {
float tmp = app.sel.freqStart;
app.sel.freqStart = app.sel.freqEnd;
app.sel.freqEnd = tmp;
}
} else {
// Drag too small - revert to full range
ClearSelection();
}
app.sel.isTimeSelecting = false;
app.sel.isFreqSelecting = false;
}
}
}
// Handle divider drag. Works whether or not the scope is currently shown
// (when hidden, the handle sits at the bottom and can be dragged back up).
if (app.loaded && !UiModalOpen()) {
// Grab the handle. The starting position is the *effective* divider,
// which is the bottom of the view (1.0) while the scope is hidden.
if (mouseNearDivider && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.isDividing = true;
app.dividerStartPos = mousePos;
app.dividerStartY = ScopeDivider();
}
if (app.isDividing && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float d = app.dividerStartY + (mousePos.y - app.dividerStartPos.y) / GetScreenHeight();
if (d >= SCOPE_COLLAPSE_DIVIDER) {
// Dragged (almost) to the bottom — hide the scope.
app.showScope = false;
} else {
// Otherwise the scope is shown; clamp the split to 30%..80%.
if (d < 0.3f) d = 0.3f;
if (d > 0.8f) d = 0.8f;
app.showScope = true;
app.dividerY = d;
}
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
app.isDividing = false;
}
}
// Processing (incremental across frames)
if (app.loaded && !app.stftComputed) {
if (app.loadingPhase == 0) {
// Initialize STFT once
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = ComputeSkipFactor(app.signal.duration);
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
app.currentSTFTSegment = 0;
app.loadingPhase = 1;
}
if (app.loadingPhase == 1) {
// Compute STFT in chunks (overview: skipFactor-strided)
int chunksPerFrame = 200;
int startSeg = app.currentSTFTSegment;
int endSeg = startSeg + chunksPerFrame;
if (endSeg > app.stft.numSegments) endSeg = app.stft.numSegments;
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, startSeg);
app.currentSTFTSegment = endSeg;
app.loadingProgress = (float)app.currentSTFTSegment / (float)app.stft.numSegments;
if (app.currentSTFTSegment >= app.stft.numSegments) {
app.loadingPhase = 2;
}
}
if (app.loadingPhase == 2) {
// Overview loaded — generate texture (NULL segments render as black)
// and transition to ready state so background processing can start.
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.loadingProgress = 1.0f;
app.stftComputed = true;
app.loadingPhase = 0; // Reset — background processing runs outside this block
app.loadingProgress = 0.0f;
app.isBgProcessing = true; // Kick off background high-res next frame
TraceLog(LOG_INFO, "STFT overview computed (%d segments, skipFactor=%d)",
app.stft.numSegments, app.skipFactor);
// Save the overview result to cache (will be overwritten when full-res completes)
SaveToCache();
}
}
// Loading overlay (drawn during STFT computation)
if (app.loaded && !app.stftComputed && app.loadingPhase >= 1) {
float scale = GetUIScale();
int w = GetScreenWidth();
int h = GetScreenHeight();
int boxW = (int)(380 * scale);
int boxH = (int)(160 * scale);
int boxX = (w - boxW) / 2;
int boxY = (h - boxH) / 2;
// Dim overlay
DrawRectangle(0, 0, w, h, (Color){ 0, 0, 0, 100 });
// Info box
DrawRectangleRec((Rectangle){ (float)boxX, (float)boxY, (float)boxW, (float)boxH }, (Color){ 40, 40, 40, 230 });
DrawRectangleLines(boxX, boxY, boxW, boxH, GRAY);
int textY = boxY + (int)(30 * scale);
int barY = textY + (int)(28 * scale);
int barW = boxW - (int)(60 * scale);
int barX = boxX + (int)(30 * scale);
// Title
DrawTextScaled("Processing...", boxX + boxW / 2 - MeasureTextScaled("Processing...", 18) / 2, textY, 18, LIGHTGRAY);
// Progress bar background
DrawRectangle(barX, barY, barW, (int)(10 * scale), DARKGRAY);
// Progress bar fill
int fillW = (int)(app.loadingProgress * barW);
if (fillW > 0) DrawRectangle(barX, barY, fillW, (int)(10 * scale), BLUE);
// Percentage text
char pctText[16];
snprintf(pctText, sizeof(pctText), "%d%%", (int)(app.loadingProgress * 100));
int pctW = MeasureTextScaled(pctText, 14);
DrawTextScaled(pctText, barX + barW / 2 - pctW / 2, barY + (int)(14 * scale), 14, WHITE);
// Duration estimate (account for skip factor — fewer segments to compute)
int estY = barY + (int)(28 * scale);
float estSec = app.signal.duration / app.signal.sampleRate * app.stft.numSegments / (200.0f * app.skipFactor);
if (estSec > 0.5f && !isnan(estSec)) {
char estText[64];
snprintf(estText, sizeof(estText), "Estimated time: %.1f sec", estSec);
int estW = MeasureTextScaled(estText, 12);
DrawTextScaled(estText, boxX + boxW / 2 - estW / 2, estY, 12, GRAY);
}
}
// Dismiss the About dialog with a click. Handled here, after the
// spectrogram input above (which is gated off while it's open), so the
// dismissing click can't fall through and start a selection/pan.
if (app.showAbout && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.showAbout = false;
}
// Rendering
BeginDrawing();
ClearBackground((Color){ 30, 30, 30, 255 });
// Layout: sidebar on left, spectrogram on right (scaled)
// Spectrogram area (excludes labels and scrollbars)
Layout L = ComputeLayout();
float renderScale = L.scale;
float sidebarWidth = L.sidebarWidth;
float labelHeight = L.labelHeight;
float scrollbarHeight = L.scrollbarHeight;
float freqLabelWidth = L.freqLabelWidth;
float vScrollbarWidth = L.vScrollbarWidth;
float topMargin = L.topMargin;
float bottomMargin = L.bottomMargin;
float spectroHeight = L.spectroHeight;
Rectangle viewBounds = L.viewBounds;
// Time labels sit just below the spectrogram
Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight };
// Horizontal scrollbar sits below the time labels
Rectangle hScrollbar = { viewBounds.x, viewBounds.y + viewBounds.height + labelHeight + 5 * renderScale, viewBounds.width, scrollbarHeight };
// Vertical scrollbar sits to the right of the spectrogram
Rectangle vScrollbar = { viewBounds.x + viewBounds.width + 5 * renderScale, viewBounds.y, vScrollbarWidth, viewBounds.height };
// Draw sidebar first (on top left)
DrawSidebar();
// Draw spectrogram (background, in its own area)
if (app.loaded && app.stftComputed) {
int imgWidth = app.spectrogramImage.width;
int imgHeight = app.spectrogramImage.height;
// Calculate visible region (time and frequency)
int visibleStartX = (int)(app.view.start * imgWidth);
int visibleEndX = (int)(app.view.end * imgWidth);
int visibleWidth = visibleEndX - visibleStartX;
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max)
int visibleStartY = (int)((1.0f - app.view.freqEnd) * imgHeight);
int visibleEndY = (int)((1.0f - app.view.freqStart) * imgHeight);
int visibleHeight = visibleEndY - visibleStartY;
// Invalidate cache if view changed or texture not valid
bool cacheInvalid = !app.visibleTextureValid ||
visibleStartX != app.cachedVisibleStart ||
visibleEndX != app.cachedVisibleEnd ||
visibleStartY != app.cachedVisibleStartY ||
visibleEndY != app.cachedVisibleEndY ||
visibleWidth <= 0 || visibleHeight <= 0;
if (cacheInvalid && visibleWidth > 0 && visibleStartX >= 0 && visibleHeight > 0 && visibleStartY >= 0) {
// Free old texture if exists
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
// Create a sub-image for the visible region
Image visibleImage = GenImageColor(visibleWidth, visibleHeight, BLACK);
Color* srcPixels = (Color*)app.spectrogramImage.data;
Color* dstPixels = (Color*)visibleImage.data;
for (int y = 0; y < visibleHeight; y++) {
for (int x = 0; x < visibleWidth; x++) {
dstPixels[y * visibleWidth + x] = srcPixels[(visibleStartY + y) * imgWidth + visibleStartX + x];
}
}
app.visibleTexture = LoadTextureFromImage(visibleImage);
UnloadImage(visibleImage);
app.cachedVisibleStart = visibleStartX;
app.cachedVisibleEnd = visibleEndX;
app.cachedVisibleStartY = visibleStartY;
app.cachedVisibleEndY = visibleEndY;
app.visibleTextureValid = true;
}
// Draw cached texture
if (app.visibleTextureValid && app.visibleTexture.id != 0) {
DrawTexturePro(app.visibleTexture,
(Rectangle){ 0, 0, visibleWidth, visibleHeight },
viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE);
}
// Draw scrollbars
// Horizontal scrollbar (time)
DrawRectangleRec(hScrollbar, DARKGRAY);
float hThumbWidth = (app.view.end - app.view.start) * hScrollbar.width;
float hThumbX = hScrollbar.x + app.view.start * hScrollbar.width;
if (hThumbWidth < 10) hThumbWidth = 10;
DrawRectangle(hThumbX, hScrollbar.y, hThumbWidth, hScrollbar.height, GRAY);
// Vertical scrollbar (frequency)
DrawRectangleRec(vScrollbar, DARKGRAY);
float vThumbHeight = (app.view.freqEnd - app.view.freqStart) * vScrollbar.height;
float vThumbY = vScrollbar.y + (1.0f - app.view.freqEnd) * vScrollbar.height;
if (vThumbHeight < 10) vThumbHeight = 10;
DrawRectangle(vScrollbar.x, vThumbY, vScrollbar.width, vThumbHeight, GRAY);
// Handle scrollbar dragging
static bool draggingH = false, draggingV = false;
static Vector2 dragStartPos;
static float dragStartViewStart, dragStartFreqViewStart;
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), hScrollbar)) {
draggingH = true;
dragStartPos = GetMousePosition();
dragStartViewStart = app.view.start;
}
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), vScrollbar)) {
draggingV = true;
dragStartPos = GetMousePosition();
dragStartFreqViewStart = app.view.freqStart;
}
if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width;
float viewWidth = app.view.end - app.view.start;
app.view.start = dragStartViewStart + dx;
app.view.end = app.view.start + viewWidth;
if (app.view.start < 0) { app.view.start = 0; app.view.end = viewWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - viewWidth; }
app.visibleTextureValid = false;
}
if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height;
float freqWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqStart = dragStartFreqViewStart - dy;
app.view.freqEnd = app.view.freqStart + freqWidth;
if (app.view.freqStart < 0) {
app.view.freqStart = 0;
app.view.freqEnd = fminf(freqWidth, 1.0f);
}
if (app.view.freqEnd > 1) {
app.view.freqEnd = 1;
app.view.freqStart = fmaxf(1.0f - freqWidth, 0.0f);
}
if (app.view.freqStart < 0) app.view.freqStart = 0;
if (app.view.freqEnd > 1) app.view.freqEnd = 1;
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
DrawSelection(viewBounds);
DrawSelectionDrag(viewBounds);
DrawPlayhead(viewBounds);
DrawLabels(viewBounds);
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
// Draw waveform scope view underneath the spectrogram
if (app.showScope && app.loaded && app.signal.samples != NULL) {
float totalArea = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale;
float scopeHeight = totalArea * (1.0f - app.dividerY) - 30 * renderScale;
app.scopeView.y = viewBounds.y + viewBounds.height + 30;
app.scopeView.x = viewBounds.x;
app.scopeView.width = viewBounds.width;
app.scopeView.height = (int)scopeHeight;
// Keep time view in sync with spectrogram view
app.scopeView.viewStart = app.view.start;
app.scopeView.viewEnd = app.view.end;
// Update waveform data
app.scopeView.data.samples = app.signal.samples;
app.scopeView.data.numSamples = app.signal.numSamples;
app.scopeView.data.sampleRate = app.signal.sampleRate;
// Show playhead if playing
if (app.isPlaying) {
DrawScopeView(&app.scopeView, app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart));
} else {
DrawScopeView(&app.scopeView, -1.0f);
}
// Scope label, tucked inside the top-left so it clears the time
// axis labels and scrollbar that sit in the band above the scope.
DrawTextScaled("Waveform", viewBounds.x + 4 * renderScale, app.scopeView.y + 3 * renderScale,
11, Fade(LIGHTGRAY, 0.5f));
}
// Draw divider line + handle. Always shown (even when the scope is
// hidden) so the handle can be grabbed at the bottom to bring it back.
if (app.loaded) {
float dividerY = viewBounds.y + viewBounds.height;
Color dividerColor = app.isDividing ? CYAN : Fade((Color){ 180, 180, 200, 255 }, 0.7f);
// Draw divider with handle
int handleW = 40;
int handleH = 10;
int handleX = viewBounds.x + viewBounds.width / 2 - handleW / 2;
int handleY = (int)dividerY - handleH / 2;
if (app.isDividing) {
// Highlighted handle while dragging
DrawRectangle(handleX, handleY, handleW, handleH, CYAN);
DrawRectangleLines(handleX, handleY, handleW, handleH, WHITE);
} else {
// Normal handle
DrawRectangle(handleX, handleY, handleW, handleH, GRAY);
DrawRectangleLines(handleX, handleY, handleW, handleH, Fade(YELLOW, 0.6f));
// Draw handle grips (3 dots)
Color gripColor = Fade(WHITE, 0.6f);
DrawPixel(handleX + handleW / 3, handleY + handleH / 2, gripColor);
DrawPixel(handleX + handleW / 2, handleY + handleH / 2, gripColor);
DrawPixel(handleX + handleW * 2 / 3, handleY + handleH / 2, gripColor);
}
// Draw line extending from handle to edges
DrawLine(viewBounds.x, (int)dividerY, handleX, (int)dividerY, dividerColor);
DrawLine(handleX + handleW, (int)dividerY, viewBounds.x + viewBounds.width, (int)dividerY, dividerColor);
// Hint that the hidden scope can be dragged back up.
if (!app.showScope && !app.isDividing) {
DrawTextScaled("drag up for scope", handleX + handleW + 8, (int)dividerY - 7, 11,
Fade(LIGHTGRAY, 0.6f));
}
}
} else if (!app.showFileBrowser) {
const char* msg1 = "Press 'O' or click 'Open File Browser' to load a WAV";
const char* msg2 = "Or drag & drop a file, or use: ./rspektrum <file.wav>";
float centerX = 350 + (GetScreenWidth() - 380 - 350) / 2;
DrawTextScaled(msg1, centerX, GetScreenHeight() / 2 - 25, 24, LIGHTGRAY);
DrawTextScaled(msg2, centerX, GetScreenHeight() / 2 + 10, 18, GRAY);
}
// Draw file browser on top (if active)
if (app.showFileBrowser) DrawFileBrowser();
// About / help dialog (topmost)
DrawAboutDialog();
// 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();
}
TraceLog(LOG_INFO, "Shutting down...");
if (mainFont.texture.id != 0) UnloadFont(mainFont);
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); }
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
UnloadTexture(colormapTexture);
FreeBrowserFiles();
FreeAllCacheEntries(&app.fftCache);
free(app.reassignBuffer);
FreeSignal(&app.signal);
CloseAudioDevice();
CloseWindow();
return 0;
}