// 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 #include #include #include #include #include // ============================================================================ // 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 "; 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; }