// 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>

#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>

// Keep raylib's framebuffer/screen size matched to the browser viewport, so the
// (immediate-mode) UI fills the page and reflows on window resize the same way
// the desktop OS window does. Going through SetWindowSize keeps the screen size,
// GL viewport, and projection consistent; the != guard avoids per-frame churn.
static void SyncCanvasToWindow(void)
{
    int w = EM_ASM_INT({ return window.innerWidth;  });
    int h = EM_ASM_INT({ return window.innerHeight; });
    if (w > 0 && h > 0 && (w != GetScreenWidth() || h != GetScreenHeight())) {
        SetWindowSize(w, h);
    }
}
#endif

// ============================================================================
// 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.
 */
// Bumped once per main-loop iteration; used to memoize per-frame queries.
static unsigned long long g_frameCounter = 1;

static bool IsUserInteracting(void)
{
    // Cached per frame: this is called from IsAppActive and from both
    // background-fill gates, and the key sweep below is 512 probes. Recomputing
    // it three times a frame was pure waste, and GetMouseWheelMove() is also
    // read by the zoom and scrollbar handlers, so it must not be consumed here.
    static unsigned long long cachedFrame = 0;
    static bool cached = false;
    if (g_frameCounter == cachedFrame) return cached;
    cachedFrame = g_frameCounter;

    cached = true;
    if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) ||
        IsMouseButtonDown(MOUSE_BUTTON_RIGHT) ||
        IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) {
        return cached;
    }
    if (GetMouseWheelMove() != 0) return cached;
    // Any key down this frame. GetKeyPressed() drains raylib's queue (which
    // would swallow presses the keymap needs), so probe the range instead.
    for (int key = 0; key < 512; key++) {
        if (IsKeyPressed(key)) return cached;
    }
    cached = false;
    return cached;
}

// Idle power management. raylib re-renders the whole scene every frame, so an
// idle window otherwise pins the GPU (and, on software GL, the CPU) at the
// target rate. We run at ACTIVE_FPS while something needs animating, then go
// fully event-driven (block until input) when idle — see the loop below.
#define ACTIVE_FPS         60   // smooth pan/zoom; idle still parks at ~0% CPU
#define IDLE_GRACE_SECONDS 0.5  // stay at full rate briefly after the last activity

/**
 * Returns true if the frame must keep redrawing at full rate: live input, a
 * moving mouse (hover readouts/tooltips), playback, in-progress loading or
 * background STFT, an active drag/pan/divider, or a counting-down notice.
 * Everything else is a static frame we can throttle.
 */
// The "Processing..." panel shown while the initial STFT runs. Factored out
// so it can also be presented once, on its own, immediately before the
// blocking compute below — otherwise the user stares at an empty window
// with no indication anything is happening.
static void DrawLoadingOverlay(void)
{
        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);

        // The overview is computed in a single blocking call, so there are no
        // intermediate frames in which to animate a percentage. Show an
        // indeterminate bar and say the window will stop responding, rather
        // than a progress bar frozen at 0% that reads as a hang.
        DrawRectangle(barX, barY, barW, (int)(10 * scale), DARKGRAY);
        DrawRectangle(barX, barY, barW, (int)(10 * scale), (Color){ 40, 90, 170, 255 });

        const char* note = "Computing spectrogram — the window will be";
        const char* note2 = "unresponsive until this finishes.";
        int nW  = MeasureTextScaled(note, 12);
        int n2W = MeasureTextScaled(note2, 12);
        DrawTextScaled(note,  boxX + boxW / 2 - nW / 2,  barY + (int)(20 * scale), 12, LIGHTGRAY);
        DrawTextScaled(note2, boxX + boxW / 2 - n2W / 2, barY + (int)(36 * scale), 12, LIGHTGRAY);

        // Segment count gives a sense of scale for a long capture.
        if (app.stft.numSegments > 0) {
            char segText[64];
            snprintf(segText, sizeof(segText), "%d segments", app.stft.numSegments);
            int sW = MeasureTextScaled(segText, 12);
            DrawTextScaled(segText, boxX + boxW / 2 - sW / 2, barY + (int)(56 * scale), 12, GRAY);
        }
}

static bool IsAppActive(void)
{
    if (IsUserInteracting()) return true;
    Vector2 d = GetMouseDelta();
    if (d.x != 0.0f || d.y != 0.0f) return true;          // hover / cursor readout
    if (IsWindowResized()) return true;
    if (app.isPlaying) return true;                        // playhead is moving
    if (app.loaded && !app.stftComputed) return true;      // STFT still loading
    if (app.isBgProcessing && !app.bgFinished) return true;// background high-res fill
    if (app.view.isPanning || app.isDividing) return true;
    if (app.sel.isDragging || app.sel.isTimeSelecting || app.sel.isFreqSelecting) return true;
    if (app.marker.dragging) return true;
    if (app.exportMessageTimer > 0.0f) return true;        // notification countdown
    if (app.autocropNoticeActive) return true;             // toast countdown + progress strip
    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 menubarHeight;     // top menubar strip
    float spectroHeight;     // height of the spectrogram (respects the scope divider AND the timeline lane)
    float timelineHeight;    // 0 if no annotations / lane hidden
    Rectangle viewBounds;    // the spectrogram drawing area
    Rectangle timelineBounds;// the annotations timeline lane (zero-sized if not shown)
} Layout;

// Number of MlnlKind rows that should be visible in the expanded timeline:
// kinds present in this file AND not filtered out by the per-kind checkboxes.
static int CountVisibleAnnotationKinds(void)
{
    int n = 0;
    for (int k = 0; k < MLNL_KIND_MAX; k++)
        if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
    return n;
}

static Layout ComputeLayout(void)
{
    Layout L;
    L.scale = GetUIScale();
    // User-resizable via the splitter; collapses to nothing.
    L.sidebarWidth = app.sidebarCollapsed ? 0.0f : app.sidebarWidth * L.scale;
    L.menubarHeight = MENUBAR_HEIGHT * L.scale;
    L.labelHeight = 15 * L.scale;
    L.scrollbarHeight = 22 * L.scale;
    // Gutter for the frequency labels. They are right-aligned to the axis and
    // compacted ("24k"), so this is comfortably wider than the widest label.
    L.freqLabelWidth = 52 * L.scale;
    L.vScrollbarWidth = 18 * L.scale;
    // Leaves room for the menubar plus the freq-range banner beneath it.
    L.topMargin = 50 * L.scale + L.menubarHeight;
    L.bottomMargin = 10 * L.scale;
    L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();

    // Timeline lane sits above the spectrogram, eating from spectro height
    // (not from the scope area). Collapsed is a thin sparkline; expanded grows
    // by one row per enabled kind. Only present when the file carries
    // annotations and the master toggle is on.
    L.timelineHeight = 0;
    if (app.annotations.loaded && app.annotations.eventCount > 0 &&
        app.showAnnotations && app.showTimeline) {
        if (app.timelineExpanded) {
            // One row per kind, tall enough that a row is actually clickable
            // rather than a hairline.
            int rows = CountVisibleAnnotationKinds();
            if (rows < 1) rows = 1;
            L.timelineHeight = (rows * 18.0f + 6.0f) * L.scale;
        } else {
            L.timelineHeight = 16.0f * L.scale;
        }
    }

    float laneX = L.sidebarWidth + L.freqLabelWidth;
    float laneW = GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale;
    L.timelineBounds = (Rectangle){ laneX, L.topMargin, laneW, L.timelineHeight };

    // Spectrogram starts below the lane (with a 2-pixel gap) and shrinks accordingly.
    float gap = (L.timelineHeight > 0) ? 2.0f * L.scale : 0.0f;
    L.viewBounds = (Rectangle){
        laneX,
        L.topMargin + L.timelineHeight + gap,
        laneW,
        L.spectroHeight - L.timelineHeight - gap
    };
    L.spectroHeight = L.viewBounds.height;
    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.marker.active = false;
    app.marker.dragging = 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.playheadSeekT = 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;

    // Drop the previous file's annotations; the caller re-parses from the new
    // path after this returns (LoadMlnlFromWav needs the source path that
    // raylib's LoadWave already consumed).
    FreeMlnl(&app.annotations);
    app.hoveredEvent = -1;
    app.hoveredTimelineEvent = -1;
    app.selectedAnnotation = -1;
    // Indices point into the events array we just freed.
    app.hoverStackCount = 0;
    // Collision analysis indexes the events we just freed.
    free(app.collisionFlags);
    app.collisionFlags = NULL;
    app.collisionCount = 0;
    app.collisionRegionCount = 0;
    app.currentCollision = -1;
    InvalidateMinimap();
    app.jumpCollisionRequest = 0;
    // Segment range belongs to the previous file's STFT; 0/0 means "whole file"
    // and lets the first rebuild pick the range for the new one.
    app.reassignSegFirst = 0;
    app.reassignSegLast = 0;
    app.autocropPending = true;  // run once when this file's STFT is ready
}

// ============================================================================
// Auto-crop: shrink the displayed freq axis + time view to where the data
// actually lives. Two independent sources, tried in priority order.
// ============================================================================

// 15% headroom above the highest annotated f_hi keeps event boxes from
// touching the top edge; 5% time padding gives breathing room around the
// outermost events without pushing them into the corners.
#define AUTOCROP_FREQ_HEADROOM   1.15f
#define AUTOCROP_TIME_PAD_FRAC   0.05f
// Confidence thresholds for the energy heuristic. If the cropped freq band
// would still cover >80% of Nyquist, or the cropped time would cover >90%
// of the timeline, the signal genuinely uses most of the available range
// and we leave the view alone (the crop would only be churn).
#define AUTOCROP_FREQ_MAX_FRAC   0.80f
#define AUTOCROP_TIME_MAX_FRAC   0.90f
// Cumulative energy fraction that defines "where signal lives". 0.99 means
// the cropped freq range holds 99% of the spectrogram's total power.
#define AUTOCROP_FREQ_ENERGY     0.99
// Per-segment activity threshold (fraction of the peak segment's energy).
// Anything below this is treated as silence at the timeline edges.
#define AUTOCROP_TIME_ACTIVITY   0.01

// Annotation-driven crop: trusts the producer. Always confident when ≥1
// annotation has f_hi or any have a non-zero time span. Returns the computed
// crop in the out-params; leaves them at "no crop" values on failure.
static bool ComputeAnnotationCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
    *outFreqMaxHz = 0.0f;
    *outViewStart = 0.0f; *outViewEnd = 1.0f;
    if (!app.annotations.loaded || app.annotations.eventCount == 0) return false;

    double fMax = 0.0;
    double tMin = 1e18, tMax = -1e18;
    bool anyFreq = false, anyTime = false;
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->has_freq && e->f_hi_hz > fMax) { fMax = e->f_hi_hz; anyFreq = true; }
        if (e->t_end >= e->t_start) {
            if (e->t_start < tMin) tMin = e->t_start;
            if (e->t_end   > tMax) tMax = e->t_end;
            anyTime = true;
        }
    }

    if (anyFreq) *outFreqMaxHz = (float)(fMax * AUTOCROP_FREQ_HEADROOM);

    if (anyTime && app.signal.duration > 0.0f) {
        double pad = (tMax - tMin) * AUTOCROP_TIME_PAD_FRAC;
        double s = tMin - pad, e = tMax + pad;
        if (s < 0.0) s = 0.0;
        if (e > app.signal.duration) e = app.signal.duration;
        if (e > s) {
            *outViewStart = (float)(s / app.signal.duration);
            *outViewEnd   = (float)(e / app.signal.duration);
        }
    }
    return anyFreq || anyTime;
}

// Energy heuristic: walk the STFT, build per-bin and per-segment energy.
// Crop freq if 99% of energy fits below 80% of Nyquist; crop time if the
// activity envelope occupies <90% of the timeline. Returns true if at least
// one axis was confidently cropped.
static bool ComputeEnergyCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
    *outFreqMaxHz = 0.0f;
    *outViewStart = 0.0f; *outViewEnd = 1.0f;
    if (app.stft.numSegments < 2) return false;
    int nbins = 0;
    for (int s = 0; s < app.stft.numSegments; s++) {
        if (app.stft.segments[s].spectrum && app.stft.segments[s].numBins > nbins)
            nbins = app.stft.segments[s].numBins;
    }
    if (nbins < 4) return false;

    int nsegs = app.stft.numSegments;
    double* binE = (double*)calloc((size_t)nbins, sizeof(double));
    double* segE = (double*)calloc((size_t)nsegs, sizeof(double));
    if (!binE || !segE) { free(binE); free(segE); return false; }

    double totalE = 0.0, segPeak = 0.0;
    for (int s = 0; s < nsegs; s++) {
        if (!app.stft.segments[s].spectrum) continue;
        int nb = app.stft.segments[s].numBins;
        for (int b = 0; b < nb; b++) {
            double a = app.stft.segments[s].spectrum[b].amplitude;
            double e = a * a;
            binE[b] += e;
            segE[s] += e;
            totalE  += e;
        }
        if (segE[s] > segPeak) segPeak = segE[s];
    }

    bool didCrop = false;

    // --- Freq axis: smallest bin whose cumulative energy reaches 99%. ---
    if (totalE > 0.0) {
        double thr = totalE * AUTOCROP_FREQ_ENERGY;
        double cum = 0.0;
        int cropBin = nbins - 1;
        for (int b = 0; b < nbins; b++) {
            cum += binE[b];
            if (cum >= thr) { cropBin = b; break; }
        }
        float fraction = (float)cropBin / (float)(nbins - 1);
        if (fraction <= AUTOCROP_FREQ_MAX_FRAC) {
            float nyq = app.signal.sampleRate * 0.5f;
            *outFreqMaxHz = fraction * nyq * AUTOCROP_FREQ_HEADROOM;
            didCrop = true;
        }
    }

    // --- Time axis: activity envelope at 1% of segment-peak energy. ---
    if (segPeak > 0.0) {
        double thr = segPeak * AUTOCROP_TIME_ACTIVITY;
        int first = -1, last = -1;
        for (int s = 0; s < nsegs; s++) {
            if (segE[s] >= thr) { if (first < 0) first = s; last = s; }
        }
        if (first >= 0 && last > first) {
            float coverage = (float)(last - first + 1) / (float)nsegs;
            if (coverage <= AUTOCROP_TIME_MAX_FRAC) {
                float s0 = (float)first / (float)nsegs;
                float s1 = (float)(last + 1) / (float)nsegs;
                float pad = (s1 - s0) * AUTOCROP_TIME_PAD_FRAC;
                s0 -= pad; s1 += pad;
                if (s0 < 0.0f) s0 = 0.0f;
                if (s1 > 1.0f) s1 = 1.0f;
                *outViewStart = s0;
                *outViewEnd   = s1;
                didCrop = true;
            }
        }
    }

    free(binE); free(segE);
    return didCrop;
}

void ApplyAutoCrop(void)
{
    if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return;
    float nyq = app.signal.sampleRate * 0.5f;

    // Compute BOTH heuristics for BOTH axes, then pick the more focused
    // result per axis. Annotations can be authoritative for freq (the
    // producer knows the band) yet wide for time (a single late `control`
    // marker can span almost the whole file even if signal activity ended
    // long before) — so we don't tie the time choice to the freq choice.
    float aFreq = 0.0f, aStart = 0.0f, aEnd = 1.0f;
    float eFreq = 0.0f, eStart = 0.0f, eEnd = 1.0f;
    ComputeAnnotationCrop(&aFreq, &aStart, &aEnd);
    ComputeEnergyCrop(&eFreq, &eStart, &eEnd);

    // ---- Freq axis: smaller cropped max wins. ----
    // Both candidates are 0 when the source didn't propose a crop; treat
    // those as "didn't propose" rather than "crop to 0".
    float freqMax = 0.0f;
    const char* freqSrc = NULL;
    if (aFreq > 0.0f && eFreq > 0.0f) {
        if (eFreq < aFreq) { freqMax = eFreq; freqSrc = "energy"; }
        else               { freqMax = aFreq; freqSrc = "annotations"; }
    } else if (aFreq > 0.0f) { freqMax = aFreq; freqSrc = "annotations"; }
    else if (eFreq > 0.0f)   { freqMax = eFreq; freqSrc = "energy"; }

    // ---- Time axis: more focused (shorter) range wins. ----
    // Each source's output is a 0..1 fraction of the signal duration; a
    // value of [0..1] means "didn't crop". We bias against picking a source
    // that's effectively the whole timeline.
    bool aShrunk = (aEnd - aStart) < 0.999f;
    bool eShrunk = (eEnd - eStart) < 0.999f;
    float vStart = 0.0f, vEnd = 1.0f;
    const char* timeSrc = NULL;
    if (aShrunk && eShrunk) {
        if ((eEnd - eStart) < (aEnd - aStart)) {
            vStart = eStart; vEnd = eEnd; timeSrc = "energy";
        } else {
            vStart = aStart; vEnd = aEnd; timeSrc = "annotations";
        }
    } else if (aShrunk) { vStart = aStart; vEnd = aEnd; timeSrc = "annotations"; }
    else if (eShrunk)   { vStart = eStart; vEnd = eEnd; timeSrc = "energy"; }

    bool freqChanged = (freqMax > 0.0f && freqMax < nyq * 0.99f);
    bool timeChanged = (timeSrc != NULL);

    if (!freqChanged && !timeChanged) {
        TraceLog(LOG_INFO, "Auto-crop: no confident source, leaving view alone");
        return;
    }

    if (freqChanged) {
        if (freqMax > nyq) freqMax = nyq;
        app.displayMaxFreqHz = freqMax;
    }
    if (timeChanged) {
        app.view.start = vStart;
        app.view.end   = vEnd;
    }
    // Fit freq view to the cropped band; otherwise a prior zoom would
    // double-zoom on top of the new crop.
    app.view.freqStart = 0.0f;
    app.view.freqEnd   = 1.0f;
    app.visibleTextureValid = false;

    // Splash message — mention per-axis source separately when they diverge
    // (e.g. freq from annotations, time from energy on a file with a stray
    // late control event).
    char freqPart[64] = "", timePart[64] = "";
    if (freqChanged) snprintf(freqPart, sizeof(freqPart), "0-%.0f Hz", freqMax);
    if (timeChanged) snprintf(timePart, sizeof(timePart), "%.2f-%.2f s",
                              vStart * app.signal.duration, vEnd * app.signal.duration);
    char srcPart[80];
    if (freqChanged && timeChanged && freqSrc && timeSrc && strcmp(freqSrc, timeSrc) != 0) {
        snprintf(srcPart, sizeof(srcPart), "freq: %s, time: %s", freqSrc, timeSrc);
    } else {
        const char* s = freqSrc ? freqSrc : timeSrc;
        snprintf(srcPart, sizeof(srcPart), "%s", s ? s : "auto");
    }
    snprintf(app.autocropNoticeMsg, sizeof(app.autocropNoticeMsg),
             "View auto-cropped to %s%s%s (%s).",
             freqPart,
             (freqChanged && timeChanged) ? ", " : "",
             timePart,
             srcPart);
    app.autocropNoticeActive = true;
    app.autocropNoticeTimer = AUTOCROP_NOTICE_SECONDS;
    TraceLog(LOG_INFO, "Auto-crop: %s", app.autocropNoticeMsg);
}

// ============================================================================
// 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)
{
    // On the web the file browser would only list this process's in-memory
    // filesystem, so "open" has to mean the host's picker instead. One entry
    // point keeps O, the File menu and the empty-state button consistent.
    if (Platform_NeedsFileUpload()) { Platform_RequestFileUpload(); return; }
    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 ActionExportWav(void)      { ExportSelectionWAV(app.exportDir); }
static void ActionToggleMarker(void)   { app.markerMode = !app.markerMode; }
// Tab flips the default left-drag gesture, the way Blender's Tab swaps
// object/edit mode. Ctrl still means "the other gesture" in either mode, so
// this only moves which one is bare.
static void ActionToggleSelectMode(void) { app.selectMode = !app.selectMode; }
static void ActionToggleSpectrum(void) { app.showSpectrum = !app.showSpectrum; }

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;
}

// Centre the view on a collision region, keeping the current zoom unless the
// region is wider than the window (then widen just enough to hold it, plus a
// margin so its edges aren't flush against the viewport).
static void JumpToCollisionRegion(int idx)
{
    if (idx < 0 || idx >= app.collisionRegionCount) return;
    if (app.signal.duration <= 0.0f) return;

    const CollisionRegion* r = &app.collisionRegions[idx];
    float t0 = (float)(r->t0 / app.signal.duration);
    float t1 = (float)(r->t1 / app.signal.duration);
    float centre = (t0 + t1) * 0.5f;

    float span = app.view.end - app.view.start;
    float need = (t1 - t0) * 1.6f;
    if (need > span) span = need;
    float minSpan = MinTimeViewWidth();
    if (span < minSpan) span = minSpan;
    if (span > 1.0f) span = 1.0f;

    app.view.start = centre - span * 0.5f;
    app.view.end   = centre + span * 0.5f;
    if (app.view.start < 0.0f) { app.view.start = 0.0f; app.view.end = span; }
    if (app.view.end > 1.0f)   { app.view.end = 1.0f;   app.view.start = 1.0f - span; }

    app.currentCollision = idx;
    app.visibleTextureValid = false;
    // Make the jump self-explanatory: without the overlay on, the view simply
    // moves somewhere with no indication of why.
    app.showCollisions = true;
    snprintf(app.exportMessage, sizeof(app.exportMessage),
             "Collision %d/%d  -  %d frames at %.2fs",
             idx + 1, app.collisionRegionCount, r->count, r->t0);
    app.exportMessageTimer = 3.0f;
}

// Next/previous collision relative to where the view is now, not to the last
// jump — so it still does the right thing after the user pans away by hand.
static void ActionNextCollision(void)
{
    if (app.collisionRegionCount <= 0 || app.signal.duration <= 0.0f) return;
    double centre = (app.view.start + app.view.end) * 0.5 * app.signal.duration;
    for (int i = 0; i < app.collisionRegionCount; i++) {
        if (app.collisionRegions[i].t0 > centre + 1e-6) { JumpToCollisionRegion(i); return; }
    }
    JumpToCollisionRegion(0);  // wrap
}

static void ActionPrevCollision(void)
{
    if (app.collisionRegionCount <= 0 || app.signal.duration <= 0.0f) return;
    double centre = (app.view.start + app.view.end) * 0.5 * app.signal.duration;
    for (int i = app.collisionRegionCount - 1; i >= 0; i--) {
        if (app.collisionRegions[i].t0 < centre - 1e-6) { JumpToCollisionRegion(i); return; }
    }
    JumpToCollisionRegion(app.collisionRegionCount - 1);  // wrap
}

static void ActionCollisionNav(void)
{
    if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) ActionPrevCollision();
    else ActionNextCollision();
}

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" },
    { KEY_W,     KEYGATE_MODAL | KEYGATE_STFT,  ActionExportWav,       "W",     "export selection WAV" },
    { KEY_M,     KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker,    "M",     "marker / ruler tool" },
    { KEY_TAB,   KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleSelectMode,"Tab",   "pan / select mode" },
    { KEY_S,     KEYGATE_MODAL | KEYGATE_STFT,  ActionToggleSpectrum,  "S",     "spectrum slice (PSD)" },
    { KEY_N,     KEYGATE_MODAL | KEYGATE_LOADED,ActionCollisionNav,    "N",     "next collision (Shift+N = prev)" },
    // 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.
// Gate check shared by the key dispatcher and the menubar, so a menu item is
// greyed out under exactly the conditions that make its shortcut a no-op.
bool KeymapActionEnabled(int key)
{
    int n;
    const KeyBinding* km = GetKeymap(&n);
    for (int i = 0; i < n; i++) {
        if (km[i].key != key) continue;
        if (!km[i].action) return false;
        if ((km[i].gate & KEYGATE_LOADED) && !app.loaded)       return false;
        if ((km[i].gate & KEYGATE_STFT)   && !app.stftComputed) return false;
        return true;
    }
    return false;
}

// Run a keymap entry by key code. The menubar routes clicks through here so an
// item and its shortcut always do the same thing. KEYGATE_MODAL is deliberately
// not checked: the menubar is itself a UI surface, and the caller only reaches
// this when no modal is up.
void InvokeKeymapAction(int key)
{
    int n;
    const KeyBinding* km = GetKeymap(&n);
    for (int i = 0; i < n; i++) {
        if (km[i].key == key && km[i].action && KeymapActionEnabled(key)) {
            km[i].action();
            return;
        }
    }
}

// Shortcut label for a key code ("O", "Home", ...), or "" if not bound.
const char* KeymapLabelFor(int key)
{
    int n;
    const KeyBinding* km = GetKeymap(&n);
    for (int i = 0; i < n; i++)
        if (km[i].key == key) return km[i].label;
    return "";
}

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();
    }
}

// ============================================================================
// Headless render (no window, no GL, no X server)
// ============================================================================

// Resolve an mLnL kind token (as emitted by MlnlKindName, plus "unknown") to
// its enum value, or -1 if unrecognized.
static int KindFromToken(const char* tok)
{
    if (strcmp(tok, "unknown") == 0) return MLNL_KIND_UNKNOWN;
    for (int k = 1; k < MLNL_KIND_MAX; k++)
        if (strcmp(tok, MlnlKindName((MlnlKind)k)) == 0) return k;
    return -1;
}

// Parse a comma-separated kind list ("tx_frame,control,...") into an enable
// mask. Sets *set true if at least one valid token was seen. Unknown tokens
// warn and are skipped.
static void ParseKindList(const char* list, bool* mask, bool* set)
{
    char buf[512];
    snprintf(buf, sizeof(buf), "%s", list);
    for (char* tok = strtok(buf, ","); tok; tok = strtok(NULL, ",")) {
        while (*tok == ' ') tok++;
        int k = KindFromToken(tok);
        if (k >= 0) { mask[k] = true; *set = true; }
        else fprintf(stderr, "rspektrum: unknown annotation kind '%s'\n", tok);
    }
}

// Compute the spectrogram for `inputArg` and write it to `renderOut` as a PNG
// without ever opening a window or touching GL. Returns a process exit code.
//   annoChoice : -1 auto, 0 force overlay off, 1 force on
//   annoOpacity: <0 keep default; else resting overlay alpha 0..1
//   kindMask   : per-kind enable flags (only consulted if kindMaskSet)
//   outW       : >0 resize output to this width (aspect-preserving); 0 = native
static int RunHeadlessRender(const char* inputArg, const char* renderOut,
                             const char* originalDir, int annoChoice,
                             float annoOpacity, const bool* kindMask,
                             bool kindMaskSet, int outW)
{
    // --- GL-free init of the global state the render touches ---
    app.colormap = COLORMAP_INFERNO;
    app.amplitudeMode = SCALE_RELATIVE;
    app.dynRangeDb = 40.0f;
    app.absoluteFloorDb = -60.0f;
    app.amplitudeFloorDb = -60.0f;
    app.amplitudeCeilingDb = 0.0f;
    app.fftSize = FFT_SIZE_DEFAULT;
    app.skipFactor = 1;
    app.displayMaxFreqHz = 0.0f;   // full Nyquist axis (native, uncropped)
    app.view.start = 0.0f; app.view.end = 1.0f;
    app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
    app.showAnnotations = true;
    // No hover in a headless render, so this single knob is the overall overlay
    // strength (outlines solid at 1.0, fills kept lighter). Default brighter
    // than the GUI's whisper-faint 0.06 so the static PNG reads on its own.
    app.annotationOpacityBase  = (annoOpacity >= 0.0f) ? annoOpacity : 0.5f;
    app.annotationOpacityHover = app.annotationOpacityBase;
    for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
    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;
    }

    // --- Load the WAV (resolve relative to the launch dir) ---
    char resolved[8192] = { 0 };
    if (!FileExists(inputArg) && originalDir[0]) {
        snprintf(resolved, sizeof(resolved), "%s/%s", originalDir, inputArg);
    }
    const char* pathToLoad = FileExists(inputArg) ? inputArg : resolved;
    if (!FileExists(pathToLoad) || !LoadWavFile(pathToLoad, &app.signal)) {
        fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
        return 1;
    }
    ResetForNewSignal();
    LoadMlnlFromWav(pathToLoad, &app.annotations);
    ComputeCollisions();

    if (annoChoice == 0) app.showAnnotations = false;
    else if (annoChoice == 1) app.showAnnotations = true;
    if (kindMaskSet)
        for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = kindMask[i];

    // --- Compute the full-resolution STFT in one shot ---
    ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
    app.skipFactor = 1;
    ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
    AutoScaleAmplitude(&app.stft);

    // --- Build the spectrogram bitmap (no GL) + bake the overlay onto it ---
    Image img = { 0 };
    BuildSpectrogramImageCPU(&app.stft, &img);
    if (img.data == NULL) {
        fprintf(stderr, "rspektrum: no spectrogram data to render\n");
        FreeSTFT(&app.stft);
        return 1;
    }

    Font font = { 0 };
    if (app.showAnnotations && app.annotations.loaded) {
        // Font size tracks the image; cap so very tall images don't blow it up.
        int fs = (int)Clamp((float)img.height / 45.0f, 12.0f, 28.0f);
        font = LoadFontCPU("resources/fonts/DejaVuSansMono.ttf", fs);
        if (font.glyphCount == 0)  // resources/ not yet CWD — try the launch dir
            font = LoadFontCPU(TextFormat("%s/resources/fonts/DejaVuSansMono.ttf", originalDir), fs);
        DrawAnnotationsToImage(&img, font);
    }

    // --- Optional resize, then export ---
    if (outW > 0 && outW != img.width) {
        int outH = (int)((float)img.height * (float)outW / (float)img.width);
        if (outH < 1) outH = 1;
        ImageResize(&img, outW, outH);
    }

    char out[8192];
    if (renderOut[0] == '/') snprintf(out, sizeof(out), "%s", renderOut);
    else                     snprintf(out, sizeof(out), "%s/%s", originalDir, renderOut);

    int outWf = img.width, outHf = img.height;
    bool ok = ExportImage(img, out);

    if (font.glyphCount > 0) UnloadFontData(font.glyphs, font.glyphCount);
    if (font.recs) RL_FREE(font.recs);
    UnloadImage(img);
    FreeSTFT(&app.stft);
    FreeMlnl(&app.annotations);
    FreeSignal(&app.signal);

    if (ok) { printf("Wrote %s (%dx%d)\n", out, outWf, outHf); return 0; }
    fprintf(stderr, "rspektrum: failed to write '%s'\n", out);
    return 1;
}

// ============================================================================
// Main Application
// ============================================================================

int main(int argc, char* argv[])
{
    // ---- Command-line arguments ----
    // Two modes:
    //   GUI:      rspektrum [input.wav]
    //   Headless: rspektrum --render OUT.png INPUT.wav [options]
    // The headless path (RunHeadlessRender, dispatched below) computes the
    // spectrogram bitmap, bakes the annotation overlay onto it, writes a PNG,
    // and exits — entirely on the CPU, with no window, no GL, and no X server.
    const char* inputArg = NULL;   // input WAV (positional)
    const char* renderOut = NULL;  // --render target; non-NULL => headless mode
    bool headless = false;
    int  annoChoice = -1;          // -1 = auto (show if present), 0 = off, 1 = on
    float annoOpacity = -1.0f;     // <0 = keep default; else override resting overlay alpha
    int  renderWidth = 0;          // >0 resize the PNG to this width (else native STFT size)
    bool kindMask[MLNL_KIND_MAX] = { false };  // which kinds to draw (if kindMaskSet)
    bool kindMaskSet = false;

    for (int i = 1; i < argc; i++) {
        const char* a = argv[i];
        if ((strcmp(a, "--render") == 0 || strcmp(a, "-r") == 0) && i + 1 < argc) {
            renderOut = argv[++i];
            headless = true;
        } else if (strcmp(a, "--annotations") == 0 || strcmp(a, "-a") == 0) {
            annoChoice = 1;
        } else if (strcmp(a, "--no-annotations") == 0) {
            annoChoice = 0;
        } else if (strncmp(a, "--annotation-opacity=", 21) == 0) {
            annoOpacity = (float)atof(a + 21);
        } else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) {
            annoOpacity = (float)atof(argv[++i]);
        } else if (strncmp(a, "--annotation-kinds=", 19) == 0) {
            ParseKindList(a + 19, kindMask, &kindMaskSet);
        } else if (strcmp(a, "--annotation-kinds") == 0 && i + 1 < argc) {
            ParseKindList(argv[++i], kindMask, &kindMaskSet);
        } else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
            renderWidth = atoi(argv[++i]);
        } else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) {
            printf(
                "rspektrum - spectrogram viewer\n\n"
                "Usage:\n"
                "  rspektrum [input.wav]                       open the GUI\n"
                "  rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n"
                "Headless render (no window, no GL, no X server) options:\n"
                "  -r, --render OUT.png   render the spectrogram bitmap to OUT.png\n"
                "  -a, --annotations      force the annotation overlay on\n"
                "      --no-annotations   force the annotation overlay off\n"
                "                         (default: shown when the WAV carries annotations)\n"
                "      --annotation-opacity=V  overlay strength 0..1 (default 0.5)\n"
                "      --annotation-kinds=LIST comma-separated kinds to draw, e.g.\n"
                "                         tx_frame,control,assertion_failed (default: all)\n"
                "      --width N          resize output to N px wide (default: native STFT size)\n"
                "  -h, --help             show this help\n\n"
                "Annotation kinds: tx_frame, tx_burst, control, channel_up, channel_down,\n"
                "  assertion_passed, assertion_failed, impairment_fire, gain_change, unknown\n");
            return 0;
        } else if (a[0] != '-') {
            if (!inputArg) inputArg = a;
        }
    }
    if (annoOpacity > 1.0f) annoOpacity = 1.0f;
    if (headless && !inputArg) {
        fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
        return 2;
    }

    // ---- Headless render: compute + write the PNG with no window/GL/X, exit.
    if (headless) {
        char cwd[4096] = { 0 };
        snprintf(cwd, sizeof(cwd), "%s", GetWorkingDirectory());
        SetTraceLogLevel(LOG_WARNING);
        return RunHeadlessRender(inputArg, renderOut, cwd, annoChoice, annoOpacity,
                                 kindMask, kindMaskSet, renderWidth);
    }

#ifdef __EMSCRIPTEN__
    // FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
    // callback sets the screen size to window.innerWidth, but the GLFW window-
    // size callback it triggers divides that by devicePixelRatio when HIGHDPI
    // is set. On a HiDPI display the framebuffer and the reported screen size
    // desync and the UI renders into a corner. UI scaling is handled by
    // GetUIScale() regardless, so the flag is unnecessary here. raylib auto-
    // resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
    SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
#else
    SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
#endif
    InitWindow(1280, 800, "Spectrogram Viewer");
    SetTargetFPS(ACTIVE_FPS);
    SetTraceLogLevel(LOG_WARNING);  // Suppress INFO texture logs
    // Audio device is opened lazily on first playback (see EnsureAudioDevice)
    // and released while idle, so an idle/backgrounded window holds no device.
    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 the TTF font. The atlas is rasterized at the physical pixel size text
    // is actually drawn at (window UI scale * monitor DPI scale) and rebuilt by
    // EnsureUIFont() when that density changes, so glyphs stay crisp on both
    // HiDPI and standard-DPI displays instead of thinning out when downscaled.
    InitUIFont("fonts/DejaVuSansMono.ttf");
    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.displayMaxFreqHz = 0.0f;  // 0 = no crop; user sets via sidebar slider
    app.showAnnotations = true;
    app.annotationsExpanded = false;
    app.annotationOpacityBase  = 0.24f;  // quiet but legible by default — signal still wins
    app.annotationOpacityHover = 0.65f;  // pop on hover / selection
    // Optional CLI override of the resting overlay alpha (e.g. for a brighter
    // GUI default). The headless render path sets its own default separately.
    if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
    app.showTimeline = false;   // opt-in: it costs spectrogram height
    app.timelineExpanded = false;
    app.hoveredTimelineEvent = -1;
    app.selectedAnnotation = -1;
    app.hoverStackCount = 0;
    app.currentCollision = -1;
    app.sidebarWidth = SIDEBAR_WIDTH_DEFAULT;
    app.sidebarCollapsed = false;
    app.openMenu = -1;
    for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
    // Control events are zero-duration log markers about the run, not signals on
    // the air. On a busy capture there are thousands of them and they clutter
    // the overlay without saying anything about what was transmitted, so they
    // start hidden; the per-kind checkbox turns them back on. (Headless
    // --render keeps every kind enabled — an export should show what was asked
    // for, not a GUI default.)
    app.annotationKindEnabled[MLNL_KIND_CONTROL] = false;
    // Off by default: the spectrogram is the primary view and the scope costs
    // it a third of the height. Toggle from the rail or with P.
    app.showScope = false;
    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 (inputArg) {
        TraceLog(LOG_INFO, "Loading file from command line: %s", inputArg);
        char resolvedPath[8192] = { 0 };

        // If the path doesn't exist as-is, try prepending original dir
        if (!FileExists(inputArg) && originalDir[0]) {
            snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, inputArg);
            TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
        }
        const char* pathToLoad = FileExists(inputArg) ? inputArg : resolvedPath;

        if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
            fileLoaded = true;
            ResetForNewSignal();
            LoadMlnlFromWav(pathToLoad, &app.annotations);
            ComputeCollisions();
            TraceLog(LOG_INFO, "File loaded successfully");
        }
    }

    if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");

    while (!WindowShouldClose())
    {
        g_frameCounter++;

        // Cursor is decided once per frame: handlers below record a request and
        // the highest-priority one is applied at the end. Reset here.
        app.cursorRequest = MOUSE_CURSOR_DEFAULT;
        app.cursorPriority = -1;

        // Set when the window is in the background but still has compute to
        // finish: the frame runs its logic and skips presenting. See the
        // power-management block below.
        bool headlessCompute = false;

#ifdef __EMSCRIPTEN__
        // Track the browser viewport (fill + reflow on resize, like desktop).
        SyncCanvasToWindow();
#else
        // Desktop power management. raylib's frame limiter partial-busy-waits
        // ~5% of every frame interval, so capping the FPS can't get idle CPU
        // below ~5% of a core. Instead we go fully event-driven when idle:
        // block in PollInputEvents() (glfwWaitEvents) until an input/window
        // event arrives, so an idle/backgrounded window costs ~0% CPU and only
        // redraws on demand.
        {
            static double lastActive = -1000.0;
            static int waiting = -1;  // -1 unset, 0 = active (poll), 1 = idle (event-wait)

            bool focused = IsWindowFocused();
            if (focused && IsAppActive()) lastActive = GetTime();

            // Work that must finish whether or not anyone is looking: the
            // initial STFT and the background high-res fill. Loading a long
            // capture takes minutes, and the user should be able to put the
            // window behind something else and come back to a finished file
            // rather than having to keep it focused to make progress.
            bool hasPendingWork = (app.loaded && !app.stftComputed) ||
                                  (app.isBgProcessing && !app.bgFinished);

            // Active = something needs animating (or just did, within the grace
            // window). Anything else is a static frame we can sleep on. Pending
            // work counts as active even unfocused, so the compute keeps running.
            bool active = (focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS)) ||
                          hasPendingWork;

            if (active) {
                if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(ACTIVE_FPS); waiting = 0; }
                // Working with the window in the background: run the compute
                // without drawing. Presenting a frame nobody can see costs GPU
                // time and, with vsync, pins the loop to the refresh rate —
                // and the fill advances a fixed number of segments per frame,
                // so that would throttle the very work we're trying to finish.
                if (!focused && hasPendingWork) {
                    headlessCompute = true;
                }
            } else {
                // Idle: no busy-wait limiter; EndDrawing's PollInputEvents blocks.
                if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; }
                // Release the output device while idle — but never mid-playback
                // (isPlaying gates it, so audio always finishes first). Reopened
                // on the next play. Safe on the unfocused path too: only closes
                // when nothing is playing.
                if (!app.isPlaying && IsAudioDeviceReady()) ReleaseAudioDevice();
                if (!focused) {
                    // Unfocused with nothing pending: block on events
                    // (refocus/close) without drawing at all. hasPendingWork is
                    // false here — a pending load takes the `active` branch
                    // above and never reaches this, so PollInputEvents can't
                    // stall the compute waiting for an input that isn't coming.
                    PollInputEvents();
                    continue;
                }
                // Focused but idle: fall through to draw this one static frame,
                // then EndDrawing blocks until the next event.
            }
        }
#endif

        // Drag & Drop
        // A file chosen through the host's picker (web only — see
        // Platform_RequestFileUpload). The bytes are already in the app's
        // filesystem by the time this reports one, so it loads like any path.
        {
            char uploaded[512];
            if (Platform_TakeUploadedFile(uploaded, (int)sizeof(uploaded)) &&
                LoadWavFile(uploaded, &app.signal)) {
                ResetForNewSignal();
                LoadMlnlFromWav(uploaded, &app.annotations);
                ComputeCollisions();
            }
        }

        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();
                        LoadMlnlFromWav(dropped.paths[0], &app.annotations);
                        ComputeCollisions();
                    }
                }
            }
            UnloadDroppedFiles(dropped);
        }
        
        // Global key bindings (table-driven; see KEYMAP/GetKeymap). The
        // order-sensitive keys (Space, Esc) are handled inline further below.
        DispatchKeymap();

        // Sidebar collision prev/next (set last frame by DrawSidebar, which
        // can't reach the static jump helpers directly).
        if (app.jumpCollisionRequest != 0) {
            if (app.jumpCollisionRequest < 0) ActionPrevCollision();
            else ActionNextCollision();
            app.jumpCollisionRequest = 0;
        }

        // Check if playback finished naturally
        if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
            // Check if sound stopped playing (IsSoundPlaying returns false when done)
            if (!IsSoundPlaying(AudioPlaybackSound)) {
                if (app.loopPlayback) {
                    // Restart from the top of the selection, ignoring any scrub
                    // offset: a loop should repeat the whole region, not the
                    // tail the last play happened to start from.
                    app.playFromT = 0.0f;
                    PlaySelectedRegion();
                    app.isPlaying = true;
                    app.playbackFinished = false;
                } else {
                    app.isPlaying = false;
                    app.playbackFinished = true;
                }
            }
            // Derive the playhead from the wall clock, not from summed frame
            // times. Audio plays on its own clock; accumulating GetFrameTime()
            // counted every frame's render work as playback time, so the marker
            // ran ahead — by seconds on a long region, and worst when zoomed out
            // where each frame does the most work.
            if (app.playDuration > 0.0f) {
                double elapsed = GetTime() - app.playStartTime;
                app.playheadT = app.playheadSeekT + (float)(elapsed / app.playDuration);
                if (app.playheadT > 1.0f) app.playheadT = 1.0f;
                if (app.playheadT < 0.0f) app.playheadT = 0.0f;
            }
        }

        // 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;

            // Selection transport. Claims its clicks before the pan/select
            // handlers below, which are gated on TransportCapturesMouse().
            UpdateSelectionTransport(viewBounds);

            // Playhead scrub. Only while stopped: dragging the marker sets
            // where the next play starts within the selection, so a subrange
            // can be replayed without redrawing the region. Claims the press
            // ahead of the pan handler, which is gated on playheadDragging.
            if (!app.isPlaying && app.loaded && app.playSelEnd > app.playSelStart) {
                float phx = PlayheadScreenX(viewBounds);
                Vector2 pm = GetMousePosition();
                bool nearPlayhead = phx >= 0.0f && fabsf(pm.x - phx) <= 6.0f * viewScale &&
                                    CheckCollisionPointRec(pm, viewBounds);
                if (nearPlayhead && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
                    app.playheadDragging = true;
                if (!IsMouseButtonDown(MOUSE_LEFT_BUTTON)) app.playheadDragging = false;

                if (app.playheadDragging) {
                    RequestCursor(MOUSE_CURSOR_RESIZE_EW, CURSOR_PRI_ACTIVE);
                    // Screen X -> time -> fraction of the SELECTION, which is
                    // what playFromT means.
                    float vt = (pm.x - viewBounds.x) / viewBounds.width;
                    float tAbs = app.view.start + vt * (app.view.end - app.view.start);
                    float selSpan = app.sel.timeEnd - app.sel.timeStart;
                    if (selSpan > 1e-6f) {
                        app.playFromT = Clamp((tAbs - app.sel.timeStart) / selSpan, 0.0f, 1.0f);
                        // Keep the drawn marker under the cursor: it is measured
                        // against playSelStart/End, not the selection.
                        app.playSelStart = app.sel.timeStart;
                        app.playSelEnd   = app.sel.timeEnd;
                        app.playheadT    = app.playFromT;
                    }
                } else if (nearPlayhead) {
                    RequestCursor(MOUSE_CURSOR_RESIZE_EW, CURSOR_PRI_HOVER);
                }
            }

            // Minimap scrub. Runs BEFORE the pan/zoom handlers below so a press
            // inside the minimap is claimed here; those handlers are gated on
            // MinimapCapturesMouse() and will skip it. Both writing app.view in
            // one frame is what made the drag jump around.
            {
                Rectangle mmBox = MinimapBounds();
                if (mmBox.width > 0.0f && app.signal.duration > 0.0f) {
                    Vector2 mm = GetMousePosition();
                    // Corner handle wins over the scrub: it sits inside the
                    // minimap, so without this a press there would both resize
                    // and start dragging the view.
                    Rectangle mmHandle = MinimapHandleRect();
                    bool onHandle = CheckCollisionPointRec(mm, mmHandle);
                    if (onHandle && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
                        app.minimapLarge = !app.minimapLarge;

                    bool overMap = !onHandle && CheckCollisionPointRec(mm, mmBox);
                    if (overMap && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
                        app.minimapDragging = true;
                    if (!IsMouseButtonDown(MOUSE_LEFT_BUTTON)) app.minimapDragging = false;
                    if (app.minimapDragging) {
                        float span = app.view.end - app.view.start;
                        float centre = Clamp((mm.x - mmBox.x) / mmBox.width, 0.0f, 1.0f);
                        app.view.start = centre - span * 0.5f;
                        app.view.end   = centre + span * 0.5f;
                        if (app.view.start < 0.0f) { app.view.start = 0.0f; app.view.end = span; }
                        if (app.view.end > 1.0f)   { app.view.end = 1.0f; app.view.start = 1.0f - span; }
                        app.visibleTextureValid = false;
                    }
                }
            }

            // Zoom with mouse wheel. Bare wheel zooms both axes together (keeps
            // the aspect ratio); Shift+wheel is time-only and Ctrl+wheel is
            // frequency-only, for when you need to stretch one axis alone.
            if (GetMousePosition().x > sidebarWidth + 5 && !MenubarCapturesMouse() &&
                !SidebarCapturesMouse() && !MinimapCapturesMouse() &&
                CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
                int wheel = GetMouseWheelMove();
                if (wheel != 0) {
                    float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;

                    bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
                    bool ctrlHeld  = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
                    bool zoomTime  = !ctrlHeld;   // Ctrl = frequency only
                    bool zoomFreq  = !shiftHeld;  // Shift = time only

                    // --- Time axis zoom (around cursor X) ---
                    if (zoomTime) {
                    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;
                    // Floor the window in SECONDS, not as a fraction of the file.
                    // A flat 2% floor meant a 30-minute recording could never show
                    // less than 36 s, while a 30-second one bottomed out at 0.6 s.
                    // The real limit is the STFT hop: once fewer than a handful of
                    // segments span the viewport there is no more detail to expose.
                    float minWidth = MinTimeViewWidth();
                    if (newWidth < minWidth) newWidth = minWidth;
                    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) ---
                    if (zoomFreq) {
                    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 is the DEFAULT left-drag gesture: dragging the view around is
            // what you do constantly, so it gets the bare gesture and selection
            // takes the modifier. app.selectMode swaps the two for people who
            // are box-selecting repeatedly. Middle-drag always pans regardless
            // of mode, and Alt is kept as a legacy pan modifier.
            bool selectHeld = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
            bool lmbPans = (app.selectMode ? selectHeld : !selectHeld) && !app.markerMode;
            bool canPan = lmbPans || IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) ||
                          IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
            // Never start a pan on a click that belongs to the UI chrome.
            bool overView = CheckCollisionPointRec(GetMousePosition(), viewBounds) &&
                            !MenubarCapturesMouse() && !SidebarCapturesMouse() &&
                            !MinimapCapturesMouse() && !app.playheadDragging &&
                            !TransportCapturesMouse(viewBounds);
            if (overView && ((canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) ||
                             IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE))) {
                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) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE))) {
                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) ||
                IsMouseButtonReleased(MOUSE_BUTTON_MIDDLE)) app.view.isPanning = false;

            // ---- Progressive full-resolution fill ----
            // After the strided overview loads, the missing segments are filled
            // in at full resolution: the visible range first (foreground, so a
            // zoom-in sharpens immediately), then a monotonic sweep of the whole
            // file whenever the user is idle. The sweep RESUMES on its own after
            // an interaction — we only gate the per-frame compute, we never latch
            // the task off. (Previously an interaction set isBgProcessing=false
            // permanently, so the fill died on the first pan/zoom and left
            // black/low-res stripes wherever the foreground pass hadn't reached.)
            bool fillingDirty = false;

            // Foreground: fill missing segments in the visible range right away.
            // Must NOT move the background sweep cursor (app.bgHighResSeg), or the
            // sweep would skip everything before the current view and strand it.
            if (app.stftComputed && !app.bgFinished && app.stft.numSegments > 0 &&
                app.view.end - app.view.start <= 0.25f) {
                int viewStartSeg = (int)(app.view.start * app.stft.numSegments);
                int viewEndSeg   = (int)(app.view.end   * app.stft.numSegments);
                if (viewStartSeg < 0) viewStartSeg = 0;
                if (viewEndSeg >= app.stft.numSegments) viewEndSeg = app.stft.numSegments - 1;
                for (int seg = viewStartSeg; seg <= viewEndSeg; seg++) {
                    if (app.stft.segments[seg].spectrum == NULL) {
                        int endSeg = seg + 50;
                        if (endSeg > viewEndSeg + 1) endSeg = viewEndSeg + 1;
                        ComputeNextHighResChunk(&app.signal, &app.stft, app.fftSize, seg, endSeg);
                        fillingDirty = true;
                        break;
                    }
                }
            }

            // Background: when idle, sweep the whole file from the cursor and
            // compute any still-missing segment (already-computed ones are skipped
            // cheaply). Done once the cursor passes the last segment.
            if (app.stftComputed && !app.bgFinished && app.stft.numSegments > 0 &&
                !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);
                fillingDirty = true;
                if (app.bgHighResSeg >= app.stft.numSegments) {
                    app.bgFinished = true;
                    InvalidateMinimap();   // gaps in the thumbnail are now filled
                    TraceLog(LOG_INFO, "Full-res fill complete (%d segments)", app.stft.numSegments);
                    SaveToCache();  // overwrite the overview-only cache entry
                }
            }

            // Stay awake while work remains so idle frames keep the sweep moving
            // (IsAppActive() checks this); let the loop sleep once it's done.
            app.isBgProcessing = !app.bgFinished;

            // Reveal freshly-filled segments. Reassigning the whole file is too
            // costly to do every frame, so throttle during the sweep and force a
            // final pass at completion. Skipped while interacting to avoid a hitch
            // mid-gesture — the next idle frame repaints.
            if (fillingDirty && (app.bgFinished || !IsUserInteracting())) {
                static double lastFillColorize = 0.0;
                double now = GetTime();

                // Throttle by how much work a repaint actually costs. The
                // reassignment walks every segment in the built range x every
                // bin, with four trig calls per live bin — zoomed out on a
                // multi-hour capture that is ~0.5 G bin-visits, and firing it
                // twice a second made the UI choppy for the whole sweep while
                // showing almost nothing new: with N segments folded into each
                // column, freshly-filled segments mostly land in columns that
                // are already drawn. Scale the interval with segsPerCol so a
                // zoomed-out view refreshes rarely and a zoomed-in one (where
                // the range is small and every segment is its own column) stays
                // responsive.
                int spc = app.reassignSegsPerCol > 0 ? app.reassignSegsPerCol : 1;
                double interval = 0.5 * (double)spc;
                if (interval > 8.0) interval = 8.0;

                if (app.bgFinished || now - lastFillColorize > interval) {
                    if (app.bgFinished) AutoScaleAmplitude(&app.stft);
                    GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
                    app.visibleTextureValid = false;
                    lastFillColorize = now;
                }
            }

        }

        // Processing (incremental across frames)
        if (app.loaded && !app.stftComputed) {
#ifdef __EMSCRIPTEN__
            // Web build: there are no worker threads, and the desktop path's
            // overview-then-deferred-high-res fill depends on many main-loop
            // iterations yielding to the browser (which made loading appear to
            // stall partway). Compute the full-resolution STFT in one shot so
            // the spectrogram is completely ready as soon as the file loads.
            ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
            app.skipFactor = 1;  // full resolution, no overview stride
            ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0); // computes every segment
            AutoScaleAmplitude(&app.stft);
            GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
            app.currentSTFTSegment = app.stft.numSegments;
            app.bgHighResSeg = app.stft.numSegments;
            app.loadingProgress = 1.0f;
            app.stftComputed = true;
            app.highResFinished = true;
            app.bgFinished = true;
            app.isBgProcessing = false;
            app.loadingPhase = 0;
            SaveToCache();
            if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
#else
            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 the whole overview in ONE blocking call, having first
                // presented the loading panel so the window isn't blank while it
                // runs.
                //
                // This used to advance 200 segments per frame, which made the
                // load frame-paced rather than CPU-bound: at ACTIVE_FPS the
                // limiter, not the FFT, set the pace, so a 478k-segment capture
                // spent over a minute doing nothing but waiting between frames.
                // The absurd tell was that backgrounding the window (which skips
                // presenting entirely) loaded the same file in seconds — the
                // progress bar was slower precisely because you were watching it.
                //
                // The UI is deliberately unresponsive for the duration: this is
                // a batch compute with nothing to interact with, and pretending
                // otherwise is what caused the problem. Normal event handling
                // resumes the moment it completes.
                //
                // The panel is drawn and presented here rather than by the main
                // draw pass, which sits far below and would only run once the
                // compute had already finished.
                BeginDrawing();
                ClearBackground((Color){ 30, 30, 30, 255 });
                DrawLoadingOverlay();
                EndDrawing();

                ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
                app.currentSTFTSegment = app.stft.numSegments;
                app.loadingProgress = 1.0f;
                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;
                InvalidateMinimap();
                app.loadingPhase = 0; // Reset — background processing runs outside this block
                app.loadingProgress = 0.0f;
                // Arm the progressive full-res fill from the start of the file.
                // A full-resolution overview (skipFactor 1) has nothing missing,
                // so mark it finished and skip the sweep entirely.
                app.bgHighResSeg = 0;
                app.bgFinished = (app.skipFactor <= 1);
                app.isBgProcessing = !app.bgFinished;
                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();
                // Run auto-crop now that we have both annotations (loaded right
                // after LoadWavFile) AND an STFT (for the energy fallback).
                // Gated on autocropPending so an FFT-size change (which routes
                // through the same loadingPhase=2 block) doesn't re-fire it.
                if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
            }
#endif // __EMSCRIPTEN__
        }

#ifndef __EMSCRIPTEN__
        // Background compute with no visible window: the STFT work above has
        // run for this frame, so skip the whole draw pass and loop straight
        // back. PollInputEvents (rather than a blocking wait) keeps refocus and
        // close responsive while the compute runs at full speed, unthrottled by
        // vsync — the fill advances a fixed number of segments per frame, so
        // presenting would cap throughput at the refresh rate.
        if (headlessCompute) {
            PollInputEvents();
            continue;
        }
#endif

        // 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;
                // Leave the marker where playback stopped rather than snapping it
                // to the start: it stays grabbable, and the next play resumes
                // from here. playheadT is a fraction of the PLAYED span, which is
                // not the selection once a scrub offset is in play — convert
                // through absolute file time, the only frame the two share.
                {
                    float stopAbs = app.playSelStart +
                                    app.playheadT * (app.playSelEnd - app.playSelStart);
                    float selSpan = app.sel.timeEnd - app.sel.timeStart;
                    app.playFromT = (selSpan > 1e-6f)
                                  ? Clamp((stopAbs - app.sel.timeStart) / selSpan, 0.0f, 0.999f)
                                  : 0.0f;
                }
                app.playheadSeekT = 0.0f;
            } 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 if (app.markerMode && app.marker.active) {
                // Clear the marker measurement first when the ruler is active.
                app.marker.active = false;
                app.marker.dragging = 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();

        // An open dropdown floats over the spectrogram; a click meant for a menu
        // item must not also start a selection or move the divider underneath it.
        // Parking the cursor off-screen is enough to make every hit test below
        // miss without threading a flag through each one.
        if (MenubarCapturesMouse() || SidebarCapturesMouse() ||
            MinimapCapturesMouse() || app.playheadDragging ||
            TransportCapturesMouse(selBounds)) mousePos = (Vector2){ -1000, -1000 };


        // Calculate divider screen position (for hover detection)
        // Divider is drawn at the BOTTOM of the spectrogram viewport. With the
        // timeline lane occupying space above the viewport, viewBounds.y was
        // shifted down — so the divider's actual screen Y is `viewBounds.y +
        // viewBounds.height`, NOT `topMargin + spectroHeight` (those two used
        // to be equal before the lane existed).
        float dividerScreenY = selBounds.y + selBounds.height;
        bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
                               mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;

        // Right-click clears the marker measurement (in marker mode) or the selection.
        if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) {
            if (app.markerMode) { app.marker.active = false; app.marker.dragging = false; }
            else ClearSelection();
        }

        // Does a left-drag mean "select" this frame? Ctrl inverts whatever
        // app.selectMode says, so either mode can do both gestures. Middle-drag
        // is always a pan, so it never selects.
        bool selCtrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
        bool selSelects = (app.selectMode ? !selCtrl : selCtrl) &&
                          !IsMouseButtonDown(MOUSE_BUTTON_MIDDLE) &&
                          !app.view.isPanning;

        // 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;
                }
            }
        }
        
        // LMB drag = box select (time + frequency) OR drag existing selection
        if (app.loaded && !UiModalOpen() && CheckCollisionPointRec(mousePos, selBounds)) {
            // Cursor for the spectrogram area. One decision, stated as a
            // request — a second block used to run unconditionally before this
            // one and the two disagreed, which is what made the shape flicker.
            if (app.sel.isDragging || app.view.isPanning) {
                RequestCursor(MOUSE_CURSOR_RESIZE_ALL, CURSOR_PRI_ACTIVE);
            } else if (mouseNearDivider && !app.isDividing) {
                RequestCursor(MOUSE_CURSOR_RESIZE_NS, CURSOR_PRI_HOVER);
            } else if (hoverInsideSelection && selSelects) {
                RequestCursor(MOUSE_CURSOR_POINTING_HAND, CURSOR_PRI_HOVER);
            } else if (selSelects) {
                // Crosshair whenever a left-drag would draw a box, so the two
                // gestures are distinguishable without guessing.
                RequestCursor(MOUSE_CURSOR_CROSSHAIR, CURSOR_PRI_HOVER);
            }

            if (app.markerMode) {
                // Marker/ruler mode: LMB press drops point A, dragging moves B,
                // release finalizes. Alt / middle-drag still pans (handled
                // above), so don't drop a marker while panning.
                RequestCursor(MOUSE_CURSOR_CROSSHAIR, CURSOR_PRI_HOVER);
                bool altPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) ||
                              IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
                if (!altPan) {
                    float vt = (mousePos.x - selBounds.x) / selBounds.width;
                    float vf = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
                    float tHere = Clamp(app.view.start + vt * (app.view.end - app.view.start), 0.0f, 1.0f);
                    float fHere = Clamp(app.view.freqStart + vf * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
                    if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
                        app.marker.t0 = tHere; app.marker.f0 = fHere;
                        app.marker.t1 = tHere; app.marker.f1 = fHere;
                        app.marker.dragging = true;
                        app.marker.active = true;
                    }
                    if (app.marker.dragging && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
                        app.marker.t1 = tHere; app.marker.f1 = fHere;
                    }
                    if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.marker.dragging = false;
                }
            } else if (selSelects) {
            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;
                    app.playFromT = 0.0f;   // new region plays from its start
                    
                    // 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 if (!hoverInsideSelection) {
                        // Sub-threshold drag outside any existing selection: treat
                        // as a click on empty space and reset to full range. A
                        // stray click *inside* the current box leaves it alone —
                        // silently clearing it there changes what Space plays.
                        // (hoverInsideSelection, not clickInsideSelection: the
                        // latter is press-frame-only and is always false here.)
                        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;
            }
        }

        // 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;
        }

        // Keep the font atlas baked at the current display density (window scale
        // * DPI). Cheap no-op unless the density actually changed (resize, or
        // dragging the window between monitors of different DPI).
        EnsureUIFont();

        // 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 is pinned to the bottom of the window, below the
        // scope. It used to sit directly under the time labels, which put it in
        // the middle of the layout competing with the scope's resize divider —
        // and the scope covered it outright at larger window sizes. It spans
        // the whole time axis either way, so the window edge is where it
        // belongs.
        Rectangle hScrollbar = { viewBounds.x,
                                 GetScreenHeight() - scrollbarHeight - 4 * 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) {
            // Rebuild the source image whenever the view moves outside the
            // segment range it was built for. The image covers the visible span
            // (plus margin) rather than the whole file — see
            // ComputeSpectrogramReassignment — so this is what keeps on-screen
            // resolution tied to the zoom level instead of to total duration.
            if (app.stft.numSegments > 0) {
                int want0 = (int)(app.view.start * app.stft.numSegments);
                int want1 = (int)ceilf(app.view.end * app.stft.numSegments);
                // Margin so small pans don't re-render every frame.
                int margin = (want1 - want0) / 4;
                want0 -= margin; want1 += margin;
                if (want0 < 0) want0 = 0;
                if (want1 > app.stft.numSegments) want1 = app.stft.numSegments;
                if (want1 <= want0) want1 = want0 + 1;

                bool needRebuild = app.reassignBuffer == NULL ||
                                   want0 < app.reassignSegFirst ||
                                   want1 > app.reassignSegLast;
                // Also re-render once the view has zoomed in far enough that the
                // cached image is being magnified — otherwise a deep zoom keeps
                // stretching the same columns instead of resolving new detail.
                if (!needRebuild && app.reassignSegsPerCol > 1) {
                    int visSegs = want1 - want0;
                    int visCols = visSegs / app.reassignSegsPerCol;
                    if (visCols < MAX_SPECTRO_IMAGE_WIDTH / 4) needRebuild = true;
                }
                if (needRebuild) {
                    app.reassignSegFirst = want0;
                    app.reassignSegLast  = want1;
                    GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage,
                                               &app.spectrogramTexture);
                    app.visibleTextureValid = false;
                }
            }

            int imgWidth = app.spectrogramImage.width;
            int imgHeight = app.spectrogramImage.height;

            // Calculate visible region (time and frequency). X is relative to
            // the segment range the image was built for, not the whole file.
            float rangeStart = 0.0f, rangeEnd = 1.0f;
            if (app.stft.numSegments > 0 && app.reassignSegLast > app.reassignSegFirst) {
                rangeStart = (float)app.reassignSegFirst / app.stft.numSegments;
                rangeEnd   = (float)app.reassignSegLast  / app.stft.numSegments;
            }
            float rangeSpan = rangeEnd - rangeStart;
            if (rangeSpan <= 0.0f) rangeSpan = 1.0f;
            float relStart = (app.view.start - rangeStart) / rangeSpan;
            float relEnd   = (app.view.end   - rangeStart) / rangeSpan;
            if (relStart < 0.0f) relStart = 0.0f;
            if (relEnd > 1.0f) relEnd = 1.0f;

            int visibleStartX = (int)(relStart * imgWidth);
            int visibleEndX = (int)(relEnd * imgWidth);
            int visibleWidth = visibleEndX - visibleStartX;
            
            // Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
            // The display-crop slider maps view.freqStart/End=1.0 to a fraction
            // of the texture's height < 1.0, effectively zooming the freq axis
            // so the cropped band fills the viewport while leaving the source
            // texture untouched.
            float cropFrac = DisplayFreqFraction();
            int visibleStartY = (int)((1.0f - app.view.freqEnd * cropFrac) * imgHeight);
            int visibleEndY = (int)((1.0f - app.view.freqStart * cropFrac) * 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. Thumb geometry uses proper "track travel" math
            // (thumb travels over width-thumb, not the whole track) so a min-size
            // thumb stays inside the track and dragging maps 1:1 to the cursor at
            // any zoom. minThumb keeps the thumb grabbable when zoomed way in.
            Vector2 mouse = GetMousePosition();
            float minThumb = 28.0f * renderScale;
            Color trackColor  = (Color){ 40, 40, 46, 255 };
            Color trackBorder = (Color){ 72, 72, 82, 255 };
            Color thumbColor  = (Color){ 110, 114, 126, 255 };
            Color thumbHot    = (Color){ 158, 164, 180, 255 };

            // Horizontal scrollbar (time)
            float hViewW = app.view.end - app.view.start;
            float hThumbW = fmaxf(hViewW * hScrollbar.width, minThumb);
            float hTravel = hScrollbar.width - hThumbW;
            float hDenom  = 1.0f - hViewW;
            float hThumbX = hScrollbar.x + (hDenom > 1e-6f ? (app.view.start / hDenom) * hTravel : 0.0f);
            Rectangle hThumb = { hThumbX, hScrollbar.y, hThumbW, hScrollbar.height };

            // Vertical scrollbar (frequency; axis flipped so freqEnd=1 is at top)
            float vViewH = app.view.freqEnd - app.view.freqStart;
            float vThumbH = fmaxf(vViewH * vScrollbar.height, minThumb);
            float vTravel = vScrollbar.height - vThumbH;
            float vDenom  = 1.0f - vViewH;
            float vThumbY = vScrollbar.y + (vDenom > 1e-6f ? ((1.0f - app.view.freqEnd) / vDenom) * vTravel : 0.0f);
            Rectangle vThumb = { vScrollbar.x, vThumbY, vScrollbar.width, vThumbH };

            // Wheel over a scrollbar: pan that axis, or zoom it with Shift.
            // The spectrogram's own wheel-zoom ignores this region, so the two
            // never both fire. Panning moves by a fraction of the visible span,
            // which keeps the feel consistent at every zoom level.
            {
                int sbWheel = GetMouseWheelMove();
                if (sbWheel != 0) {
                    bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
                    bool overH = CheckCollisionPointRec(mouse, hScrollbar);
                    bool overV = CheckCollisionPointRec(mouse, vScrollbar);

                    if (overH) {
                        if (shiftHeld) {
                            // Zoom time about the view centre.
                            float centre = (app.view.start + app.view.end) * 0.5f;
                            float w = hViewW * ((sbWheel > 0) ? 0.8f : 1.25f);
                            float minW = MinTimeViewWidth();
                            w = Clamp(w, minW, 1.0f);
                            app.view.start = centre - w * 0.5f;
                            app.view.end   = centre + w * 0.5f;
                        } else {
                            float step = hViewW * 0.15f * (sbWheel > 0 ? -1.0f : 1.0f);
                            app.view.start += step;
                            app.view.end   += step;
                        }
                        if (app.view.start < 0.0f) { app.view.end -= app.view.start; app.view.start = 0.0f; }
                        if (app.view.end > 1.0f)   { app.view.start -= (app.view.end - 1.0f); app.view.end = 1.0f; }
                        if (app.view.start < 0.0f) app.view.start = 0.0f;
                        app.visibleTextureValid = false;
                    } else if (overV) {
                        if (shiftHeld) {
                            float centre = (app.view.freqStart + app.view.freqEnd) * 0.5f;
                            float h = vViewH * ((sbWheel > 0) ? 0.8f : 1.25f);
                            h = Clamp(h, 0.001f, 1.0f);
                            app.view.freqStart = centre - h * 0.5f;
                            app.view.freqEnd   = centre + h * 0.5f;
                        } else {
                            // Wheel up moves toward higher frequencies (the top).
                            float step = vViewH * 0.15f * (sbWheel > 0 ? 1.0f : -1.0f);
                            app.view.freqStart += step;
                            app.view.freqEnd   += step;
                        }
                        if (app.view.freqStart < 0.0f) { app.view.freqEnd -= app.view.freqStart; app.view.freqStart = 0.0f; }
                        if (app.view.freqEnd > 1.0f)   { app.view.freqStart -= (app.view.freqEnd - 1.0f); app.view.freqEnd = 1.0f; }
                        if (app.view.freqStart < 0.0f) app.view.freqStart = 0.0f;
                        app.visibleTextureValid = false;
                    }
                }
            }

            // Handle scrollbar interaction. A press on the empty track jumps the
            // view so the thumb re-centers under the cursor, then drags from there.
            static bool draggingH = false, draggingV = false;
            static Vector2 dragStartPos;
            static float dragStartViewStart, dragStartFreqViewStart;

            if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(mouse, hScrollbar)) {
                if (!CheckCollisionPointRec(mouse, hThumb) && hTravel > 0.0f) {
                    float frac = Clamp((mouse.x - hScrollbar.x - hThumbW * 0.5f) / hTravel, 0.0f, 1.0f);
                    app.view.start = frac * hDenom;
                    app.view.end = app.view.start + hViewW;
                    app.visibleTextureValid = false;
                }
                draggingH = true;
                dragStartPos = mouse;
                dragStartViewStart = app.view.start;
            }
            if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(mouse, vScrollbar)) {
                if (!CheckCollisionPointRec(mouse, vThumb) && vTravel > 0.0f) {
                    float frac = Clamp((mouse.y - vScrollbar.y - vThumbH * 0.5f) / vTravel, 0.0f, 1.0f);
                    app.view.freqEnd = 1.0f - frac * vDenom;
                    app.view.freqStart = app.view.freqEnd - vViewH;
                    app.visibleTextureValid = false;
                }
                draggingV = true;
                dragStartPos = mouse;
                dragStartFreqViewStart = app.view.freqStart;
            }
            if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
                float dx = (hTravel > 0.0f) ? ((mouse.x - dragStartPos.x) / hTravel) * hDenom : 0.0f;
                app.view.start = dragStartViewStart + dx;
                app.view.end = app.view.start + hViewW;
                if (app.view.start < 0) { app.view.start = 0; app.view.end = hViewW; }
                if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - hViewW; }
                app.visibleTextureValid = false;
            }
            if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
                float dy = (vTravel > 0.0f) ? ((mouse.y - dragStartPos.y) / vTravel) * vDenom : 0.0f;
                app.view.freqStart = dragStartFreqViewStart - dy;
                app.view.freqEnd = app.view.freqStart + vViewH;
                if (app.view.freqStart < 0) {
                    app.view.freqStart = 0;
                    app.view.freqEnd = fminf(vViewH, 1.0f);
                }
                if (app.view.freqEnd > 1) {
                    app.view.freqEnd = 1;
                    app.view.freqStart = fmaxf(1.0f - vViewH, 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; }

            // Draw tracks + thumbs after interaction so hover/active state and the
            // jumped position are reflected this frame.
            DrawRectangleRec(hScrollbar, trackColor);
            DrawRectangleLinesEx(hScrollbar, 1.0f, trackBorder);
            DrawRectangleRounded(hThumb, 0.5f, 4,
                (draggingH || CheckCollisionPointRec(mouse, hThumb)) ? thumbHot : thumbColor);

            DrawRectangleRec(vScrollbar, trackColor);
            DrawRectangleLinesEx(vScrollbar, 1.0f, trackBorder);
            DrawRectangleRounded(vThumb, 0.5f, 4,
                (draggingV || CheckCollisionPointRec(mouse, vThumb)) ? thumbHot : thumbColor);

            if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
            // Timeline lane sits above the spectrogram. Drawn before the
            // overlays so its hover/selection state is set for the same frame.
            if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds);
            DrawAnnotations(viewBounds);
            DrawSelection(viewBounds);
            DrawSelectionTransport(viewBounds);
            DrawSelectionDrag(viewBounds);
            DrawMarkers(viewBounds);
            DrawPlayhead(viewBounds);
            DrawLabels(viewBounds);
            if (!UiModalOpen() && app.hoveredEvent < 0 && app.hoveredTimelineEvent < 0)
                DrawCursorReadout(viewBounds);
            DrawSpectrumPanel(viewBounds);
            float maxFreq = EffectiveMaxFreqHz();
            float freqMin = app.view.freqStart * maxFreq;
            float freqMax = app.view.freqEnd * maxFreq;
            // Pin to the top margin so the timeline lane (which lives between
            // the banner and the spectrogram) doesn't shove the banner down.
            DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
                    viewBounds.x, topMargin - 30, 20, LIGHTGRAY);
            
            // Draw waveform scope view underneath the spectrogram
            if (app.showScope && app.loaded && app.signal.samples != NULL) {
                // Sits below the time labels; the horizontal scrollbar is pinned
                // to the window's bottom edge, so the scope stops just above it
                // rather than having to clear it on the way down.
                float scopeTop = labelHeight + 8 * renderScale;
                float scopeY = viewBounds.y + viewBounds.height + scopeTop;
                float scopeBottom = hScrollbar.y - 6 * renderScale;
                float scopeHeight = scopeBottom - scopeY;
                if (scopeHeight < 20 * renderScale) scopeHeight = 20 * renderScale;
                app.scopeView.y = scopeY;
                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.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart));
                } else {
                    DrawScopeView(&app.scopeView, -1.0f);
                }
                // Echo the annotation overlay onto the scope so selecting an
                // event in the timeline highlights both surfaces at once.
                DrawAnnotationsOnScope((Rectangle){
                    (float)app.scopeView.x, (float)app.scopeView.y,
                    (float)app.scopeView.width, (float)app.scopeView.height });
                // 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) {
            // Empty state. Centred by measuring the text against the area right
            // of the rail — the old form hardcoded the 320px sidebar's width and
            // left-aligned at that offset, so it sat well off-centre once the
            // rail shrank to 46px.
            Layout eL = ComputeLayout();
            float areaX = eL.sidebarWidth;
            float areaW = GetScreenWidth() - areaX;
            float cy = GetScreenHeight() * 0.5f;

            // On the web the app's filesystem is an in-memory sandbox, so the
            // file browser can only list what this process wrote. Point the user
            // at the upload button instead of a browser that shows nothing.
            bool web = Platform_NeedsFileUpload();
            const char* msg1 = web ? "Drop a WAV here, or click Open file"
                                   : "Press 'O' or click 'Open File Browser' to load a WAV";
            const char* msg2 = web ? "(annotated mLnL captures show their overlay automatically)"
                                   : "Or drag & drop a file, or use: ./rspektrum <file.wav>";

            float w1 = MeasureTextScaled(msg1, 24);
            float w2 = MeasureTextScaled(msg2, 18);
            DrawTextScaled(msg1, areaX + (areaW - w1) * 0.5f, cy - 46, 24, LIGHTGRAY);
            DrawTextScaled(msg2, areaX + (areaW - w2) * 0.5f, cy - 12, 18, GRAY);

            if (web) {
                float bw = 150.0f * eL.scale, bh = 34.0f * eL.scale;
                Rectangle btn = { areaX + (areaW - bw) * 0.5f, cy + 22, bw, bh };
                bool over = CheckCollisionPointRec(GetMousePosition(), btn);
                DrawRectangleRec(btn, over ? (Color){ 60, 90, 120, 255 }
                                           : (Color){ 45, 65, 90, 255 });
                DrawRectangleLinesEx(btn, 1, (Color){ 130, 180, 230, 255 });
                float tw = MeasureTextScaled("Open file", 16);
                DrawTextScaled("Open file", btn.x + (bw - tw) * 0.5f,
                               btn.y + 8 * eL.scale, 16, WHITE);
                if (over && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
                    Platform_RequestFileUpload();
            }
        }

        // Draw file browser on top (if active)
        if (app.showFileBrowser) DrawFileBrowser();

        // Auto-crop notice modal — drawn below About so About still wins if
        // both happened to be up at once (shouldn't happen in practice).
        DrawAutocropNotice();

        if (!UiModalOpen()) DrawMinimap();

        // Sidebar popouts and the menubar float above the main UI but below
        // modals: their panels have to paint over the spectrogram and scope.
        if (!UiModalOpen()) DrawSidebarPopouts();
        if (!UiModalOpen()) DrawMenubar();

        // 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);
        }

        // Hold the export message for a few seconds, then clear it.
        if (app.exportMessageTimer > 0.0f) {
            app.exportMessageTimer -= GetFrameTime();
            if (app.exportMessageTimer <= 0.0f) app.exportMessage[0] = '\0';
        }

        // Apply the frame's cursor decision (see RequestCursor).
        SetMouseCursor(app.cursorRequest);

        EndDrawing();
    }

    TraceLog(LOG_INFO, "Shutting down...");
    if (mainFont.texture.id != 0) UnloadFont(mainFont);
    if (IsAudioDeviceReady() && 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);
    free(app.collisionFlags);
    for (int i = 0; i < 2; i++)
        if (app.minimapTexture[i].id != 0) UnloadTexture(app.minimapTexture[i]);
    FreeWaveEnvelope(&app.scopeView.envelope);
    FreeMlnl(&app.annotations);
    FreeSignal(&app.signal);
    if (IsAudioDeviceReady()) CloseAudioDevice();
    CloseWindow();
    return 0;
}
